Compare commits
124 Commits
669eaa7c65
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bec7dd17e | ||
|
|
a0339a5526 | ||
|
|
65cae4a5e8 | ||
|
|
c8cc0646cb | ||
|
|
e8057fe517 | ||
|
|
378023c53d | ||
|
|
ff3e863032 | ||
|
|
8fc32f18ce | ||
|
|
0cd9ea915e | ||
|
|
f0e7f73ec1 | ||
|
|
567f9267e8 | ||
|
|
1dc5bf4377 | ||
|
|
31d3ec8367 | ||
|
|
d739f58bb5 | ||
|
|
18e675a5b2 | ||
|
|
a3fc838855 | ||
|
|
d5043053e0 | ||
|
|
c932dd19d9 | ||
|
|
c532ad21bf | ||
|
|
0e95bd9160 | ||
|
|
e312cce4e1 | ||
|
|
5587722800 | ||
|
|
0451b6630c | ||
|
|
f77fb79cd2 | ||
|
|
1247b51d9e | ||
|
|
7342c60952 | ||
|
|
328bd2c3b4 | ||
|
|
db87a214fd | ||
|
|
ad95b09046 | ||
|
|
1e95ca979b | ||
|
|
1cae9ac311 | ||
|
|
72bd2e11b4 | ||
|
|
69b3c663c0 | ||
|
|
f470a39ad2 | ||
|
|
e2f287d3d8 | ||
|
|
914e438793 | ||
|
|
6266c5f721 | ||
|
|
f564c30ae2 | ||
|
|
a5ce46359a | ||
|
|
b45953e567 | ||
|
|
36d1b9c038 | ||
|
|
56bcbcdd5c | ||
|
|
9b9bfde843 | ||
|
|
164a917d95 | ||
|
|
96c0aa592c | ||
|
|
d8520d9714 | ||
|
|
873d668653 | ||
|
|
4e257a7ca4 | ||
|
|
d0bb6729cd | ||
|
|
32ede3e3ce | ||
|
|
5da78e5e30 | ||
|
|
cb108faaf8 | ||
|
|
611b82ccde | ||
|
|
64d8f9d904 | ||
|
|
6f452a9a8b | ||
|
|
20fe5637c1 | ||
|
|
9bf8cf831d | ||
|
|
9f4a1141ef | ||
|
|
cb818f4bfa | ||
|
|
9c195ff5cb | ||
|
|
54d32c9163 | ||
|
|
0b5ab73963 | ||
|
|
956387471d | ||
|
|
78fd9e026e | ||
|
|
4d6fb06e02 | ||
|
|
8944f8bb44 | ||
|
|
1b178767ab | ||
|
|
7d10653c41 | ||
|
|
b7a03614bc | ||
|
|
49c5324352 | ||
|
|
193a4d6ee6 | ||
|
|
3182da8d92 | ||
|
|
6839cf2a33 | ||
|
|
775b5c062e | ||
|
|
e31dac5c9c | ||
|
|
c2bd1b34f0 | ||
|
|
cfd49ff69e | ||
|
|
1f7b08b74f | ||
|
|
240b373f68 | ||
|
|
09a043431e | ||
|
|
9b21d6aee8 | ||
|
|
e4c8535f42 | ||
|
|
97a2dd8743 | ||
|
|
17d9328c62 | ||
|
|
e10090b9ef | ||
|
|
4f1594390e | ||
|
|
1f4e8a5958 | ||
|
|
d64139d9d1 | ||
|
|
2779502f3b | ||
|
|
9f1e2c9ff5 | ||
|
|
dd99c5dd74 | ||
|
|
b607677f30 | ||
|
|
20fe83d889 | ||
|
|
c7782d554f | ||
|
|
ea65611690 | ||
|
|
17b29edd14 | ||
|
|
3438260090 | ||
|
|
0bd00a3044 | ||
|
|
d301825e50 | ||
|
|
6193e28587 | ||
|
|
bfdf64975c | ||
|
|
ea800e5e2a | ||
|
|
cfff594732 | ||
|
|
0fa330a357 | ||
|
|
a6c85e3658 | ||
|
|
e0aca0f883 | ||
|
|
a77b0c1221 | ||
|
|
393a3c25fd | ||
|
|
8c7a2741b0 | ||
|
|
865c6ed796 | ||
|
|
14542b6e33 | ||
|
|
de7053644b | ||
|
|
f1e0b92f47 | ||
|
|
bead6f1811 | ||
|
|
7769dbc9f4 | ||
|
|
74ca5ee35f | ||
|
|
38973a014e | ||
|
|
fc8b4b164b | ||
|
|
eb63df2000 | ||
|
|
53bd574660 | ||
|
|
581ba01d8d | ||
|
|
9db42d6cc1 | ||
|
|
ab24786d2a | ||
|
|
1aca4c4a41 |
@@ -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` |
|
| 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 `@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` |
|
||||||
|
|||||||
@@ -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) |
|
| 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 `@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 |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
name: Unit & Component Tests
|
name: Unit & Component Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
image: mcr.microsoft.com/playwright:v1.60.0-noble
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -29,6 +29,10 @@ 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
|
||||||
@@ -148,7 +152,10 @@ jobs:
|
|||||||
path: frontend/test-results/screenshots/
|
path: frontend/test-results/screenshots/
|
||||||
|
|
||||||
# ─── OCR Service Unit Tests ───────────────────────────────────────────────────
|
# ─── OCR Service Unit Tests ───────────────────────────────────────────────────
|
||||||
# Only spell_check.py, test_confidence.py, test_sender_registry.py — no ML stack required.
|
# Only stdlib/lightweight tests — no ML stack (PyTorch/Surya/Kraken) 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
|
||||||
@@ -160,11 +167,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" pytest pytest-asyncio
|
run: pip install "pyspellchecker==0.9.0" "fastapi==0.115.6" 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 -v
|
run: python -m pytest test_spell_check.py test_confidence.py test_sender_registry.py test_tmpdir.py -v
|
||||||
working-directory: ocr-service
|
working-directory: ocr-service
|
||||||
|
|
||||||
# ─── Backend Unit & Slice Tests ───────────────────────────────────────────────
|
# ─── Backend Unit & Slice Tests ───────────────────────────────────────────────
|
||||||
|
|||||||
@@ -252,20 +252,20 @@ jobs:
|
|||||||
URL="https://$HOST"
|
URL="https://$HOST"
|
||||||
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
||||||
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||||
RESOLVE="--resolve $HOST:443:$HOST_IP"
|
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||||
curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null
|
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
||||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||||
# fail this check rather than pass it silently.
|
# fail this check rather than pass it silently.
|
||||||
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||||
# Permissions-Policy denies APIs the app does not use (camera,
|
# Permissions-Policy denies APIs the app does not use (camera,
|
||||||
# microphone, geolocation). A regression that loosens or drops the
|
# microphone, geolocation). A regression that loosens or drops the
|
||||||
# header now fails the smoke step.
|
# header now fails the smoke step.
|
||||||
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||||
status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||||
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
||||||
echo "All smoke checks passed"
|
echo "All smoke checks passed"
|
||||||
|
|
||||||
|
|||||||
@@ -181,28 +181,31 @@ 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 pins to the bridge gateway IP (the host), not 127.0.0.1
|
# --resolve stored as a Bash array so "${RESOLVE[@]}" expands to two
|
||||||
# — see nightly.yml for the full network topology explanation.
|
# separate arguments; a quoted string would pass the flag and its value
|
||||||
|
# as one token and curl would reject it as an unknown option.
|
||||||
|
# Gateway detection via /proc/net/route — no iproute2 dependency.
|
||||||
|
# See nightly.yml for the full network topology explanation.
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
HOST="archiv.raddatz.cloud"
|
HOST="archiv.raddatz.cloud"
|
||||||
URL="https://$HOST"
|
URL="https://$HOST"
|
||||||
HOST_IP=$(ip route show default | awk '/default/ {print $3}')
|
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
||||||
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via 'ip route'"; exit 1; }
|
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||||
RESOLVE="--resolve $HOST:443:$HOST_IP"
|
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||||
curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null
|
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
||||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||||
# fail this check rather than pass it silently.
|
# fail this check rather than pass it silently.
|
||||||
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||||
# Permissions-Policy denies APIs the app does not use (camera,
|
# Permissions-Policy denies APIs the app does not use (camera,
|
||||||
# microphone, geolocation). A regression that loosens or drops the
|
# microphone, geolocation). A regression that loosens or drops the
|
||||||
# header now fails the smoke step.
|
# header now fails the smoke step.
|
||||||
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||||
status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||||
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
||||||
echo "All smoke checks passed"
|
echo "All smoke checks passed"
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
|
|||||||
```
|
```
|
||||||
backend/src/main/java/org/raddatz/familienarchiv/
|
backend/src/main/java/org/raddatz/familienarchiv/
|
||||||
├── audit/ Audit logging
|
├── audit/ Audit logging
|
||||||
|
├── auth/ AuthService, AuthSessionController, LoginRequest, LoginRateLimiter, RateLimitProperties (Spring Session JDBC)
|
||||||
├── 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)
|
||||||
@@ -93,7 +94,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, auth controllers
|
└── user/ User domain — AppUser, UserGroup, UserService
|
||||||
```
|
```
|
||||||
|
|
||||||
### Layering Rules
|
### Layering Rules
|
||||||
@@ -159,7 +160,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
|
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
@@ -266,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`.
|
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ if (!result.response.ok) {
|
|||||||
return { person: result.data! }; // non-null assertion is safe after the ok check
|
return { person: result.data! }; // non-null assertion is safe after the ok check
|
||||||
```
|
```
|
||||||
|
|
||||||
For multipart/form-data (file uploads): bypass the typed client and use raw `fetch` — the client cannot handle it.
|
For multipart/form-data (file uploads): bypass the typed client and use `event.fetch` directly — never global `fetch`. The typed client cannot handle multipart bodies, but `event.fetch` is still required so that `handleFetch` injects the session cookie.
|
||||||
|
|
||||||
### Date handling
|
### Date handling
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ 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
|
||||||
@@ -40,7 +41,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, auth controllers
|
└── user/ # User domain — AppUser, UserGroup, UserService
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
||||||
@@ -96,7 +97,10 @@ 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).
|
- Read methods: no annotation (default non-transactional) — **except** when the method returns
|
||||||
|
an entity whose lazy associations must remain accessible to the caller after the method
|
||||||
|
returns. In that case, use `@Transactional(readOnly = true)` to keep the Hibernate session
|
||||||
|
open. Removing this annotation causes `LazyInitializationException` in production. See ADR-022.
|
||||||
- Cross-domain access goes through the other domain's service, never its repository.
|
- Cross-domain access goes through the other domain's service, never its repository.
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|||||||
@@ -69,6 +69,10 @@
|
|||||||
<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>
|
||||||
@@ -176,11 +180,16 @@
|
|||||||
<artifactId>flyway-database-postgresql</artifactId>
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Caffeine cache for in-memory rate limiting -->
|
<!-- Caffeine cache + Bucket4j 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>
|
||||||
|
|||||||
@@ -35,7 +35,22 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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 : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
class JdbcSessionRevocationAdapter implements SessionRevocationPort {
|
||||||
|
|
||||||
|
private final JdbcIndexedSessionRepository sessionRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||||
|
int count = 0;
|
||||||
|
for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) {
|
||||||
|
if (!id.equals(currentSessionId)) {
|
||||||
|
sessionRepository.deleteById(id);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int revokeAllSessions(String principalName) {
|
||||||
|
var sessions = sessionRepository.findByPrincipalName(principalName);
|
||||||
|
sessions.keySet().forEach(sessionRepository::deleteById);
|
||||||
|
return sessions.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import com.github.benmanes.caffeine.cache.LoadingCache;
|
||||||
|
import io.github.bucket4j.Bandwidth;
|
||||||
|
import io.github.bucket4j.Bucket;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class LoginRateLimiter {
|
||||||
|
|
||||||
|
private final LoadingCache<String, Bucket> byIpEmail;
|
||||||
|
private final LoadingCache<String, Bucket> byIp;
|
||||||
|
private final int maxPerIpEmail;
|
||||||
|
private final int maxPerIp;
|
||||||
|
private final int windowMinutes;
|
||||||
|
|
||||||
|
public LoginRateLimiter(RateLimitProperties props) {
|
||||||
|
this.maxPerIpEmail = props.getMaxAttemptsPerIpEmail();
|
||||||
|
this.maxPerIp = props.getMaxAttemptsPerIp();
|
||||||
|
this.windowMinutes = props.getWindowMinutes();
|
||||||
|
|
||||||
|
this.byIpEmail = Caffeine.newBuilder()
|
||||||
|
.expireAfterAccess(windowMinutes, TimeUnit.MINUTES)
|
||||||
|
.build(key -> newBucket(maxPerIpEmail, windowMinutes));
|
||||||
|
|
||||||
|
this.byIp = Caffeine.newBuilder()
|
||||||
|
.expireAfterAccess(windowMinutes, TimeUnit.MINUTES)
|
||||||
|
.build(key -> newBucket(maxPerIp, windowMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: This cache is node-local (in-memory). In a multi-replica deployment,
|
||||||
|
// effective limits would be multiplied by replica count.
|
||||||
|
// For the current single-VPS setup this is the correct, simplest implementation.
|
||||||
|
|
||||||
|
public void checkAndConsume(String ip, String email) {
|
||||||
|
long retryAfterSeconds = windowMinutes * 60L;
|
||||||
|
String key = ip + ":" + email.toLowerCase(Locale.ROOT);
|
||||||
|
if (!byIpEmail.get(key).tryConsume(1)) {
|
||||||
|
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
||||||
|
"Too many login attempts from " + ip, retryAfterSeconds);
|
||||||
|
}
|
||||||
|
if (!byIp.get(ip).tryConsume(1)) {
|
||||||
|
// Refund the ipEmail token so IP-level blocking does not erode the per-email quota.
|
||||||
|
byIpEmail.get(key).addTokens(1);
|
||||||
|
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
||||||
|
"Too many login attempts from " + ip, retryAfterSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void invalidateOnSuccess(String ip, String email) {
|
||||||
|
byIpEmail.invalidate(ip + ":" + email.toLowerCase(Locale.ROOT));
|
||||||
|
byIp.invalidate(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bucket newBucket(int limit, int minutes) {
|
||||||
|
return Bucket.builder()
|
||||||
|
.addLimit(Bandwidth.builder()
|
||||||
|
.capacity(limit)
|
||||||
|
.refillGreedy(limit, Duration.ofMinutes(minutes))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
public record LoginRequest(String email, String password) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
class NoOpSessionRevocationAdapter implements SessionRevocationPort {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int revokeAllSessions(String principalName) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties("rate-limit.login")
|
||||||
|
@Data
|
||||||
|
public class RateLimitProperties {
|
||||||
|
private int maxAttemptsPerIpEmail = 10;
|
||||||
|
private int maxAttemptsPerIp = 20;
|
||||||
|
private int windowMinutes = 15;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class SessionRevocationConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SessionRevocationPort sessionRevocationPort(
|
||||||
|
@Autowired(required = false) JdbcIndexedSessionRepository sessionRepository) {
|
||||||
|
if (sessionRepository != null) {
|
||||||
|
return new JdbcSessionRevocationAdapter(sessionRepository);
|
||||||
|
}
|
||||||
|
return new NoOpSessionRevocationAdapter();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
public interface SessionRevocationPort {
|
||||||
|
int revokeOtherSessions(String currentSessionId, String principalName);
|
||||||
|
int revokeAllSessions(String principalName);
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ 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;
|
||||||
|
|
||||||
@@ -21,6 +22,15 @@ 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.
|
||||||
@@ -118,24 +128,27 @@ public class Document {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private ScriptType scriptType = ScriptType.UNKNOWN;
|
private ScriptType scriptType = ScriptType.UNKNOWN;
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.LAZY)
|
||||||
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
@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
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "sender_id")
|
@JoinColumn(name = "sender_id")
|
||||||
private Person sender;
|
private Person sender;
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.LAZY)
|
||||||
@JoinTable(name = "document_tags", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
@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.EAGER)
|
@ElementCollection(fetch = FetchType.LAZY)
|
||||||
@CollectionTable(name = "document_training_labels", joinColumns = @JoinColumn(name = "document_id"))
|
@CollectionTable(name = "document_training_labels", joinColumns = @JoinColumn(name = "document_id"))
|
||||||
@Column(name = "label")
|
@Column(name = "label")
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
|
@BatchSize(size = 50)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ 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;
|
||||||
@@ -23,6 +25,18 @@ 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);
|
||||||
@@ -30,17 +44,21 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
|
// 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);
|
||||||
|
|
||||||
// Findet alle Dokumente mit einem bestimmten Status
|
// Callers access only status/id scalar fields — no graph needed.
|
||||||
// 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)")
|
||||||
@@ -55,12 +73,15 @@ 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 " +
|
||||||
@@ -75,6 +96,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
@Param("to") LocalDate to,
|
@Param("to") LocalDate to,
|
||||||
Sort sort);
|
Sort sort);
|
||||||
|
|
||||||
|
@EntityGraph("Document.full")
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
"LEFT JOIN d.receivers r " +
|
"LEFT JOIN d.receivers r " +
|
||||||
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
|
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ 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));
|
||||||
@@ -635,7 +636,7 @@ public class DocumentService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
|
@Transactional(readOnly = true)
|
||||||
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"))
|
||||||
@@ -843,6 +844,7 @@ 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));
|
||||||
|
|||||||
@@ -10,11 +10,21 @@ 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() {
|
||||||
@@ -25,6 +35,11 @@ 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) {
|
||||||
@@ -39,6 +54,11 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -50,4 +70,12 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,8 +62,16 @@ public enum ErrorCode {
|
|||||||
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 */
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ public class GlobalExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(DomainException.class)
|
@ExceptionHandler(DomainException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
|
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
|
||||||
return ResponseEntity
|
var builder = ResponseEntity.status(ex.getStatus());
|
||||||
.status(ex.getStatus())
|
if (ex.getRetryAfterSeconds() != null) {
|
||||||
.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
|
builder = builder.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds()));
|
||||||
|
}
|
||||||
|
return builder.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.raddatz.familienarchiv.importing;
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.poi.ss.usermodel.*;
|
import org.apache.poi.ss.usermodel.*;
|
||||||
@@ -31,6 +33,7 @@ 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;
|
||||||
@@ -53,9 +56,33 @@ public class MassImportService {
|
|||||||
|
|
||||||
public enum State { IDLE, RUNNING, DONE, FAILED }
|
public enum State { IDLE, RUNNING, DONE, FAILED }
|
||||||
|
|
||||||
public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {}
|
public record SkippedFile(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
|
||||||
|
) {}
|
||||||
|
|
||||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
public record ImportStatus(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) State state,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String statusCode,
|
||||||
|
@JsonIgnore String message,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int processed,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<SkippedFile> skippedFiles,
|
||||||
|
LocalDateTime startedAt
|
||||||
|
) {
|
||||||
|
// Note: @Schema on a record accessor method is not picked up by SpringDoc; the
|
||||||
|
// "skipped" count is a computed convenience field derived from skippedFiles.size().
|
||||||
|
@JsonProperty("skipped")
|
||||||
|
public int skipped() { return skippedFiles.size(); }
|
||||||
|
|
||||||
|
/** Defensive-copy constructor — callers cannot mutate the stored list after construction. */
|
||||||
|
public ImportStatus {
|
||||||
|
skippedFiles = List.copyOf(skippedFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record ProcessResult(int processed, List<SkippedFile> skippedFiles) {}
|
||||||
|
|
||||||
|
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
|
|
||||||
public ImportStatus getStatus() {
|
public ImportStatus getStatus() {
|
||||||
return currentStatus;
|
return currentStatus;
|
||||||
@@ -117,22 +144,22 @@ public class MassImportService {
|
|||||||
if (currentStatus.state() == State.RUNNING) {
|
if (currentStatus.state() == State.RUNNING) {
|
||||||
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
||||||
}
|
}
|
||||||
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now());
|
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, List.of(), LocalDateTime.now());
|
||||||
try {
|
try {
|
||||||
File spreadsheet = findSpreadsheetFile();
|
File spreadsheet = findSpreadsheetFile();
|
||||||
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
||||||
int processed = processRows(readSpreadsheet(spreadsheet));
|
ProcessResult result = processRows(readSpreadsheet(spreadsheet));
|
||||||
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
||||||
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
|
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.",
|
||||||
processed, currentStatus.startedAt());
|
result.processed(), result.skippedFiles(), currentStatus.startedAt());
|
||||||
} catch (NoSpreadsheetException e) {
|
} catch (NoSpreadsheetException e) {
|
||||||
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
||||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
||||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
"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, "IMPORT_FAILED_INTERNAL",
|
||||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,8 +281,10 @@ public class MassImportService {
|
|||||||
|
|
||||||
// --- Import logic (works on neutral List<String> rows) ---
|
// --- Import logic (works on neutral List<String> rows) ---
|
||||||
|
|
||||||
private int processRows(List<List<String>> rows) {
|
private ProcessResult processRows(List<List<String>> rows) {
|
||||||
int count = 0;
|
int processed = 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);
|
||||||
@@ -266,18 +295,58 @@ 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);
|
|
||||||
count++;
|
if (fileOnDisk.isPresent()) {
|
||||||
|
try {
|
||||||
|
if (!isPdfMagicBytes(fileOnDisk.get())) {
|
||||||
|
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
|
||||||
|
skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
|
||||||
|
skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<String> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
|
||||||
|
if (skipReason.isPresent()) {
|
||||||
|
skippedFiles.add(new SkippedFile(filename, skipReason.get()));
|
||||||
|
} else {
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return count;
|
return new ProcessResult(processed, skippedFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// package-private: Mockito spy in tests can override to inject IOException
|
||||||
|
InputStream openFileStream(File file) throws IOException {
|
||||||
|
return new FileInputStream(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isPdfMagicBytes(File file) throws IOException {
|
||||||
|
try (InputStream is = openFileStream(file)) {
|
||||||
|
byte[] header = is.readNBytes(4);
|
||||||
|
return header.length == 4
|
||||||
|
&& header[0] == 0x25 // %
|
||||||
|
&& header[1] == 0x50 // P
|
||||||
|
&& header[2] == 0x44 // D
|
||||||
|
&& header[3] == 0x46; // F
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports a single document row.
|
||||||
|
*
|
||||||
|
* @return empty Optional on success; an Optional containing the skip reason on failure/skip.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
protected void importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
protected Optional<String> importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
||||||
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
|
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;
|
return Optional.of("ALREADY_EXISTS");
|
||||||
}
|
}
|
||||||
|
|
||||||
String archiveBox = getCell(cells, colBox);
|
String archiveBox = getCell(cells, colBox);
|
||||||
@@ -313,7 +382,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;
|
return Optional.of("S3_UPLOAD_FAILED");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +424,7 @@ public class MassImportService {
|
|||||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||||
}
|
}
|
||||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||||
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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.*;
|
||||||
@@ -9,6 +10,9 @@ import org.raddatz.familienarchiv.user.DisplayNameFormatter;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
// prevents infinite recursion in JSON serialization; see ADR-022 for lazy-fetch context
|
||||||
|
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "persons")
|
@Table(name = "persons")
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,42 @@
|
|||||||
package org.raddatz.familienarchiv.security;
|
package org.raddatz.familienarchiv.security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.core.annotation.Order;
|
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.Customizer;
|
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||||
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.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;
|
||||||
|
|
||||||
@@ -37,6 +52,19 @@ 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
|
@Bean
|
||||||
@Order(1)
|
@Order(1)
|
||||||
public SecurityFilterChain managementFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain managementFilterChain(HttpSecurity http) throws Exception {
|
||||||
@@ -62,27 +90,19 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
// CSRF is intentionally disabled. With the cookie-promotion model
|
// CSRF protection via CookieCsrfTokenRepository (NFR-SEC-103).
|
||||||
// (auth_token cookie → Authorization header via AuthTokenCookieFilter,
|
// The backend sets an XSRF-TOKEN cookie (not HttpOnly so JS can read it).
|
||||||
// see #520), every authenticated request to /api/* now carries the
|
// All state-changing requests must include X-XSRF-TOKEN matching the cookie.
|
||||||
// credential automatically once the cookie is set. The CSRF defence
|
// See ADR-022 and issue #524 for the full security rationale.
|
||||||
// for state-changing endpoints is therefore LOAD-BEARING on:
|
.csrf(csrf -> csrf
|
||||||
//
|
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||||
// 1. SameSite=strict on the auth_token cookie (login/+page.server.ts).
|
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
|
||||||
// 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.
|
// Actuator endpoints are governed by managementFilterChain (@Order(1)) above.
|
||||||
// The permitAll() lines here are a belt-and-suspenders fallback in case any
|
|
||||||
// actuator path escapes that chain's securityMatcher. See docs/adr/017.
|
|
||||||
auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll();
|
auth.requestMatchers("/actuator/health", "/actuator/prometheus").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
|
||||||
@@ -102,9 +122,18 @@ 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()))
|
||||||
// Erlaubt Login via Browser-Popup oder REST-Header (Authorization: Basic ...)
|
// Return 401 for unauthenticated requests; 403+CSRF_TOKEN_MISSING for CSRF failures.
|
||||||
.httpBasic(Customizer.withDefaults())
|
.exceptionHandling(ex -> ex
|
||||||
.formLogin(form -> form.usernameParameter("email"));
|
.authenticationEntryPoint(
|
||||||
|
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))
|
||||||
|
.accessDeniedHandler((req, res, e) -> {
|
||||||
|
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
res.setContentType("application/json;charset=UTF-8");
|
||||||
|
ErrorCode code = (e instanceof CsrfException)
|
||||||
|
? ErrorCode.CSRF_TOKEN_MISSING
|
||||||
|
: ErrorCode.FORBIDDEN;
|
||||||
|
res.getWriter().write(ERROR_WRITER.writeValueAsString(Map.of("code", code.name())));
|
||||||
|
}));
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package org.raddatz.familienarchiv.tag;
|
|||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
|
// prevents infinite recursion in JSON serialization; see ADR-022 for lazy-fetch context
|
||||||
|
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
|
||||||
@Entity
|
@Entity
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ public class InviteListItemDTO {
|
|||||||
private String status;
|
private String status;
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String shareableUrl;
|
private String shareableUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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;
|
||||||
@@ -32,6 +33,7 @@ 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;
|
||||||
@@ -85,6 +87,8 @@ public class PasswordResetService {
|
|||||||
|
|
||||||
resetToken.setUsed(true);
|
resetToken.setUsed(true);
|
||||||
tokenRepository.save(resetToken);
|
tokenRepository.save(resetToken);
|
||||||
|
|
||||||
|
authService.revokeAllSessions(user.getEmail());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ 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;
|
||||||
@@ -26,13 +30,15 @@ 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.AllArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/")
|
@RequestMapping("/api/")
|
||||||
@AllArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class UserController {
|
public class UserController {
|
||||||
private UserService userService;
|
private final 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) {
|
||||||
@@ -56,9 +62,14 @@ 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}")
|
||||||
@@ -101,6 +112,18 @@ 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
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:
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ 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
|
||||||
@@ -143,3 +150,9 @@ sentry:
|
|||||||
enable-tracing: true
|
enable-tracing: true
|
||||||
ignored-exceptions-for-type:
|
ignored-exceptions-for-type:
|
||||||
- org.raddatz.familienarchiv.exception.DomainException
|
- org.raddatz.familienarchiv.exception.DomainException
|
||||||
|
|
||||||
|
rate-limit:
|
||||||
|
login:
|
||||||
|
max-attempts-per-ip-email: 10
|
||||||
|
max-attempts-per-ip: 20
|
||||||
|
window-minutes: 15
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- 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
|
||||||
|
);
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test for {@link JdbcSessionRevocationAdapter} that verifies
|
||||||
|
* session rows are actually written to / removed from the {@code spring_session}
|
||||||
|
* table backed by a real PostgreSQL container.
|
||||||
|
*
|
||||||
|
* <p>Sessions are inserted via raw JDBC to avoid the module-access restriction on
|
||||||
|
* {@code JdbcIndexedSessionRepository.JdbcSession}. The {@link SessionRevocationPort}
|
||||||
|
* bean injected here is the real {@link JdbcSessionRevocationAdapter} wired by Spring.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class JdbcSessionRevocationAdapterIntegrationTest {
|
||||||
|
|
||||||
|
@MockitoBean S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired SessionRevocationPort adapter;
|
||||||
|
@Autowired JdbcTemplate jdbcTemplate;
|
||||||
|
@Autowired TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
|
private static final String PRINCIPAL = "revocation-it@test.de";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clearSessions() {
|
||||||
|
// spring_session_attributes cascades on delete
|
||||||
|
transactionTemplate.execute(status -> {
|
||||||
|
jdbcTemplate.update("DELETE FROM spring_session");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a minimal {@code spring_session} row attributed to {@value #PRINCIPAL}
|
||||||
|
* and returns its opaque primary-key ID (the value the repository uses as the
|
||||||
|
* session identifier, not the {@code SESSION_ID} column which holds the public token).
|
||||||
|
*
|
||||||
|
* <p>Column layout mirrors the Flyway-managed schema shipped with the app:
|
||||||
|
* PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME, MAX_INACTIVE_INTERVAL,
|
||||||
|
* EXPIRY_TIME, PRINCIPAL_NAME.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Inserts a persisted session row for {@value #PRINCIPAL} and returns the
|
||||||
|
* {@code SESSION_ID} column value — this is the opaque identifier that
|
||||||
|
* {@link JdbcIndexedSessionRepository} uses as the session's public key
|
||||||
|
* (returned by {@code JdbcSession.getId()} and expected by
|
||||||
|
* {@link JdbcIndexedSessionRepository#deleteById}).
|
||||||
|
*
|
||||||
|
* <p>The inserts run inside a {@link TransactionTemplate} so the rows are
|
||||||
|
* committed before {@code findByPrincipalName} opens its own transaction and
|
||||||
|
* can see the data via Read Committed isolation.
|
||||||
|
*/
|
||||||
|
private String insertSession() {
|
||||||
|
String primaryId = UUID.randomUUID().toString();
|
||||||
|
// SESSION_ID is the value used by JdbcSession.getId() and findByPrincipalName map keys.
|
||||||
|
String sessionId = UUID.randomUUID().toString();
|
||||||
|
long now = Instant.now().toEpochMilli();
|
||||||
|
long expiry = now + 8L * 3600 * 1000; // 8-hour TTL
|
||||||
|
transactionTemplate.execute(status -> {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO spring_session
|
||||||
|
(PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME,
|
||||||
|
MAX_INACTIVE_INTERVAL, EXPIRY_TIME, PRINCIPAL_NAME)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
primaryId, sessionId, now, now, 28800, expiry, PRINCIPAL);
|
||||||
|
// Spring Session's listSessionsByPrincipalName query joins spring_session_attributes;
|
||||||
|
// insert a minimal attribute row so the session appears in the result set.
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO spring_session_attributes
|
||||||
|
(SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
primaryId, "test_attr", new byte[]{0});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return sessionId; // the public key used by JdbcSession.getId() and deleteById()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tests ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeAllSessions_removes_every_row_from_spring_session_table() {
|
||||||
|
insertSession();
|
||||||
|
insertSession();
|
||||||
|
|
||||||
|
int count = adapter.revokeAllSessions(PRINCIPAL);
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
assertThat(jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM spring_session WHERE PRINCIPAL_NAME = ?",
|
||||||
|
Long.class, PRINCIPAL))
|
||||||
|
.isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeOtherSessions_deletes_non_current_rows_and_keeps_current_session() {
|
||||||
|
String keepId = insertSession();
|
||||||
|
insertSession();
|
||||||
|
insertSession();
|
||||||
|
|
||||||
|
int count = adapter.revokeOtherSessions(keepId, PRINCIPAL);
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
// The current session row must still be present (keyed by SESSION_ID)
|
||||||
|
assertThat(jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM spring_session WHERE SESSION_ID = ?",
|
||||||
|
Long.class, keepId))
|
||||||
|
.isEqualTo(1L);
|
||||||
|
// The total for this principal is now exactly 1
|
||||||
|
assertThat(jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM spring_session WHERE PRINCIPAL_NAME = ?",
|
||||||
|
Long.class, PRINCIPAL))
|
||||||
|
.isEqualTo(1L);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class JdbcSessionRevocationAdapterTest {
|
||||||
|
|
||||||
|
@Mock JdbcIndexedSessionRepository sessionRepository;
|
||||||
|
@InjectMocks JdbcSessionRevocationAdapter adapter;
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Test
|
||||||
|
void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() {
|
||||||
|
var sessions = new HashMap<String, Object>();
|
||||||
|
sessions.put("session-keep", null);
|
||||||
|
sessions.put("session-del-1", null);
|
||||||
|
sessions.put("session-del-2", null);
|
||||||
|
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
|
||||||
|
|
||||||
|
int count = adapter.revokeOtherSessions("session-keep", "user@test.de");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
verify(sessionRepository, never()).deleteById("session-keep");
|
||||||
|
verify(sessionRepository).deleteById("session-del-1");
|
||||||
|
verify(sessionRepository).deleteById("session-del-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Test
|
||||||
|
void revokeAllSessions_deletes_all_sessions_for_principal() {
|
||||||
|
var sessions = new HashMap<String, Object>();
|
||||||
|
sessions.put("session-1", null);
|
||||||
|
sessions.put("session-2", null);
|
||||||
|
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
|
||||||
|
|
||||||
|
int count = adapter.revokeAllSessions("user@test.de");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
verify(sessionRepository).deleteById("session-1");
|
||||||
|
verify(sessionRepository).deleteById("session-2");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class LoginRateLimiterTest {
|
||||||
|
|
||||||
|
private LoginRateLimiter rateLimiter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
RateLimitProperties props = new RateLimitProperties();
|
||||||
|
props.setMaxAttemptsPerIpEmail(10);
|
||||||
|
props.setMaxAttemptsPerIp(20);
|
||||||
|
props.setWindowMinutes(15);
|
||||||
|
rateLimiter = new LoginRateLimiter(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tenth_attempt_from_same_ip_email_succeeds() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eleventh_attempt_from_same_ip_email_throws_TOO_MANY_LOGIN_ATTEMPTS() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blocked_attempt_carries_retry_after_seconds_equal_to_window_duration() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getRetryAfterSeconds())
|
||||||
|
.isEqualTo(15 * 60L)); // windowMinutes=15 → 900 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_after_10_failures_resets_ip_email_bucket() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||||
|
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twentyfirst_attempt_from_same_ip_across_different_emails_throws() {
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user" + i + "@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "attacker@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "other@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void email_lookup_is_case_insensitive_so_mixed_case_shares_the_same_bucket() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "User@Example.COM");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidateOnSuccess_is_case_insensitive_so_mixed_case_clears_the_bucket() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimiter.invalidateOnSuccess("1.2.3.4", "User@Example.COM");
|
||||||
|
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts() {
|
||||||
|
// Use a tighter limiter so the phantom-consumption effect is observable.
|
||||||
|
// ipEmail=3, IP=3: exhausting IP via one email burns the other email's quota with the old code.
|
||||||
|
RateLimitProperties props = new RateLimitProperties();
|
||||||
|
props.setMaxAttemptsPerIpEmail(3);
|
||||||
|
props.setMaxAttemptsPerIp(3);
|
||||||
|
props.setWindowMinutes(15);
|
||||||
|
LoginRateLimiter tightLimiter = new LoginRateLimiter(props);
|
||||||
|
|
||||||
|
// Exhaust the per-IP bucket using "user@"
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
tightLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three blocked attempts for "target@" while IP is exhausted
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
assertThatThrownBy(() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A successful login for "user@" resets the IP bucket but NOT target@'s ipEmail bucket
|
||||||
|
tightLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||||
|
|
||||||
|
// After IP reset: "target@" must NOT be blocked by an exhausted ipEmail bucket.
|
||||||
|
// With the old code, 3 blocked attempts burned all 3 ipEmail tokens → blocked here.
|
||||||
|
// With the fix, tokens are refunded on each blocked attempt → still has capacity.
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,15 @@ class RateLimitInterceptorTest {
|
|||||||
verify(response).setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
verify(response).setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blocked_response_includes_retry_after_header() throws Exception {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
interceptor.preHandle(request, response, null);
|
||||||
|
}
|
||||||
|
interceptor.preHandle(request, response, null);
|
||||||
|
verify(response).setHeader("Retry-After", "60");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void different_ips_have_independent_limits() throws Exception {
|
void different_ips_have_independent_limits() throws Exception {
|
||||||
HttpServletRequest other = mock(HttpServletRequest.class);
|
HttpServletRequest other = mock(HttpServletRequest.class);
|
||||||
|
|||||||
@@ -44,10 +44,12 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.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})
|
||||||
@@ -214,14 +216,14 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createDocument_returns401_whenUnauthenticated() throws Exception {
|
void createDocument_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents"))
|
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||||
.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"))
|
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +237,7 @@ class DocumentControllerTest {
|
|||||||
.build();
|
.build();
|
||||||
when(documentService.createDocument(any(), any())).thenReturn(doc);
|
when(documentService.createDocument(any(), any())).thenReturn(doc);
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents"))
|
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +246,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(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +254,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(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +271,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(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +280,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()))
|
.delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +288,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()))
|
.delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +297,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))
|
.delete("/api/documents/" + id).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,14 +305,14 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.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"))
|
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +328,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))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||||
.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())
|
||||||
@@ -345,7 +347,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))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||||
.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"))
|
||||||
@@ -360,7 +362,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))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||||
.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"))
|
||||||
@@ -490,7 +492,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"))
|
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created").isEmpty())
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
.andExpect(jsonPath("$.updated").isEmpty())
|
.andExpect(jsonPath("$.updated").isEmpty())
|
||||||
@@ -640,7 +642,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")
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||||
.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());
|
||||||
@@ -649,7 +651,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")
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||||
.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());
|
||||||
@@ -659,7 +661,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")
|
mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf())
|
||||||
.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());
|
||||||
@@ -671,7 +673,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")
|
mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf())
|
||||||
.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());
|
||||||
@@ -682,7 +684,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")
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||||
.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());
|
||||||
@@ -696,7 +698,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))
|
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +715,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))
|
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf()))
|
||||||
.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"));
|
||||||
@@ -726,7 +728,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))
|
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile).with(csrf()))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +745,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))
|
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf()))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,7 +802,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))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf()))
|
||||||
.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()))
|
||||||
@@ -827,7 +829,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))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf()))
|
||||||
.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()))
|
||||||
@@ -859,7 +861,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))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf()))
|
||||||
.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"))
|
||||||
@@ -883,7 +885,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))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata).with(csrf()))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,7 +906,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))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
||||||
@@ -926,7 +928,7 @@ class DocumentControllerTest {
|
|||||||
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
||||||
}
|
}
|
||||||
|
|
||||||
mockMvc.perform(builder)
|
mockMvc.perform(builder.with(csrf()))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||||
}
|
}
|
||||||
@@ -945,7 +947,7 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(UUID.randomUUID().toString())))
|
.content(bulkBody(UUID.randomUUID().toString())))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -954,7 +956,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")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(UUID.randomUUID().toString())))
|
.content(bulkBody(UUID.randomUUID().toString())))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -965,7 +967,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")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"documentIds\":[]}"))
|
.content("{\"documentIds\":[]}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -976,7 +978,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")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -990,7 +992,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")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(ids)))
|
.content(bulkBody(ids)))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -1009,7 +1011,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")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -1025,7 +1027,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")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(ids)))
|
.content(bulkBody(ids)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -1042,7 +1044,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")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.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())
|
||||||
@@ -1061,7 +1063,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")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.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())
|
||||||
@@ -1137,7 +1139,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() + "\"]}"))
|
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1146,7 +1148,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() + "\"]}"))
|
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,7 +1157,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\":[]}"))
|
.content("{\"ids\":[]}").with(csrf()))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,7 +1174,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()))
|
.content(sb.toString()).with(csrf()))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||||
}
|
}
|
||||||
@@ -1187,7 +1189,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 + "\"]}"))
|
.content("{\"ids\":[\"" + id + "\"]}").with(csrf()))
|
||||||
.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"))
|
||||||
@@ -1208,7 +1210,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")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(badId.toString())))
|
.content(bulkBody(badId.toString())))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -1232,7 +1234,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")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.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())
|
||||||
@@ -1337,4 +1339,16 @@ 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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||||
|
import org.raddatz.familienarchiv.dashboard.DashboardService;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||||
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
|
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that lazy-loaded associations on {@link Document} are accessible after a service
|
||||||
|
* method returns — i.e. no {@link org.hibernate.LazyInitializationException} is thrown outside
|
||||||
|
* the Hibernate session that loaded the entity.
|
||||||
|
*
|
||||||
|
* <p><b>Known limitation:</b> calling {@code getDocumentById} (or any other service method) from
|
||||||
|
* within an already-open transaction is not covered here. When an outer transaction is active,
|
||||||
|
* the service's own {@code @Transactional} merges into it and Hibernate keeps the same session
|
||||||
|
* open, so the lazy-init guard behaves differently than in a non-transactional caller. This is a
|
||||||
|
* known constraint of the test setup, not a bug in the production code.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class DocumentLazyLoadingTest {
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
PersonRepository personRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
TagRepository tagRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
DocumentService documentService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
DashboardService dashboardService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
AuditLogQueryService auditLogQueryService;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
documentRepository.deleteAll();
|
||||||
|
tagRepository.deleteAll();
|
||||||
|
personRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDocumentById_tagsAndReceiversAccessible_afterReturnFromService() {
|
||||||
|
Person sender = savedPerson("Max", "LzSender");
|
||||||
|
Person receiver = savedPerson("Anna", "LzReceiver");
|
||||||
|
Tag tag = savedTag("LzTag");
|
||||||
|
Document doc = savedDocument("LazyTest", "lazy_test.pdf", sender, Set.of(receiver), Set.of(tag));
|
||||||
|
|
||||||
|
Document result = documentService.getDocumentById(doc.getId());
|
||||||
|
|
||||||
|
// Only the collection access itself is in assertThatCode — guards against LazyInitializationException.
|
||||||
|
// Value assertions live outside so failures surface as AssertionError, not as unexpected exception.
|
||||||
|
assertThatCode(() -> {
|
||||||
|
result.getTags().size();
|
||||||
|
result.getReceivers().size();
|
||||||
|
}).doesNotThrowAnyException();
|
||||||
|
assertThat(result.getTags()).isNotEmpty();
|
||||||
|
result.getTags().forEach(t -> assertThat(t.getName()).isNotNull());
|
||||||
|
assertThat(result.getReceivers()).isNotEmpty();
|
||||||
|
result.getReceivers().forEach(r -> assertThat(r.getLastName()).isNotNull());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getRecentActivity_collectionsAccessibleAfterReturn() {
|
||||||
|
Person sender = savedPerson("Hans", "RaSender");
|
||||||
|
Tag tag = savedTag("RaTag");
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
savedDocument("RaDoc " + i, "ra_doc" + i + ".pdf", sender, Set.of(), Set.of(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Document> results = documentService.getRecentActivity(3);
|
||||||
|
|
||||||
|
// Access lazy fields inside assertThatCode — guards against LazyInitializationException.
|
||||||
|
// Value assertions live outside so failures surface as AssertionError, not as unexpected exception.
|
||||||
|
assertThatCode(() -> {
|
||||||
|
results.forEach(d -> d.getSender().getLastName());
|
||||||
|
results.forEach(d -> d.getTags().size());
|
||||||
|
}).doesNotThrowAnyException();
|
||||||
|
results.forEach(d -> assertThat(d.getSender()).isNotNull());
|
||||||
|
results.forEach(d -> assertThat(d.getSender().getLastName()).isNotNull());
|
||||||
|
results.forEach(d -> assertThat(d.getTags()).isNotEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_receiverSort_doesNotThrowLazyInitializationException() {
|
||||||
|
Person sender = savedPerson("Hans", "SrSender");
|
||||||
|
Person receiver = savedPerson("Anna", "SrReceiver");
|
||||||
|
Tag tag = savedTag("SrTag");
|
||||||
|
savedDocument("SrDoc", "sr_doc.pdf", sender, Set.of(receiver), Set.of(tag));
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.RECEIVER, "asc", null,
|
||||||
|
PageRequest.of(0, 20));
|
||||||
|
assertThat(result.totalElements()).isGreaterThan(0);
|
||||||
|
assertThatCode(() ->
|
||||||
|
result.items().forEach(i -> i.document().getSender().getLastName()))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
|
||||||
|
Person sender = savedPerson("Hans", "SsSender");
|
||||||
|
Tag tag = savedTag("SsTag");
|
||||||
|
savedDocument("SsDoc", "ss_doc.pdf", sender, Set.of(), Set.of(tag));
|
||||||
|
|
||||||
|
assertThatCode(() -> documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.SENDER, "asc", null,
|
||||||
|
PageRequest.of(0, 20)))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException() {
|
||||||
|
Person sender = savedPerson("Max", "DsSender");
|
||||||
|
Person receiver = savedPerson("Anna", "DsReceiver");
|
||||||
|
Document doc = savedDocument("DashboardTest", "dashboard_test.pdf", sender, Set.of(receiver), Set.of());
|
||||||
|
UUID fakeUserId = UUID.randomUUID();
|
||||||
|
when(auditLogQueryService.findMostRecentDocumentForUser(any())).thenReturn(Optional.of(doc.getId()));
|
||||||
|
when(auditLogQueryService.findRecentContributorsPerDocument(any())).thenReturn(java.util.Map.of());
|
||||||
|
|
||||||
|
assertThatCode(() -> dashboardService.getResume(fakeUserId))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Person savedPerson(String firstName, String lastName) {
|
||||||
|
return personRepository.save(Person.builder().firstName(firstName).lastName(lastName).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Tag savedTag(String name) {
|
||||||
|
return tagRepository.save(Tag.builder().name(name).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document savedDocument(String title, String filename, Person sender,
|
||||||
|
Set<Person> receivers, Set<Tag> tags) {
|
||||||
|
return documentRepository.save(Document.builder()
|
||||||
|
.title(title).originalFilename(filename)
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.receivers(new HashSet<>(receivers))
|
||||||
|
.tags(new HashSet<>(tags))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package org.raddatz.familienarchiv.document;
|
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;
|
||||||
@@ -21,6 +25,7 @@ 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;
|
||||||
@@ -55,6 +60,12 @@ 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
|
||||||
@@ -490,6 +501,117 @@ class DocumentRepositoryTest {
|
|||||||
assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId());
|
assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── query-count — entity-graph assertions ────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Hans").lastName("QcSender").build());
|
||||||
|
Person receiver = personRepository.save(Person.builder().firstName("Anna").lastName("QcReceiver").build());
|
||||||
|
Tag tag = tagRepository.save(Tag.builder().name("QcTag").build());
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("QcDoc " + i).originalFilename("qcdoc" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.receivers(new HashSet<>(Set.of(receiver)))
|
||||||
|
.tags(new HashSet<>(Set.of(tag)))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
|
||||||
|
stats.setStatisticsEnabled(true);
|
||||||
|
stats.clear();
|
||||||
|
|
||||||
|
Specification<Document> allDocs = (root, query, cb) -> null;
|
||||||
|
documentRepository.findAll(allDocs, PageRequest.of(0, 10));
|
||||||
|
|
||||||
|
assertThat(stats.getPrepareStatementCount())
|
||||||
|
.as("@EntityGraph(Document.list) must load 10 docs in ≤5 statements, not N+1")
|
||||||
|
.isLessThanOrEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findById_loadsSenderReceiversAndTagsInAtMostTwoStatements() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Max").lastName("FbSender").build());
|
||||||
|
Set<Person> receivers = new HashSet<>();
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
receivers.add(personRepository.save(
|
||||||
|
Person.builder().firstName("R" + i).lastName("FbReceiver").build()));
|
||||||
|
}
|
||||||
|
Set<Tag> tags = new HashSet<>();
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
tags.add(tagRepository.save(Tag.builder().name("FbTag" + i).build()));
|
||||||
|
}
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("FindByIdQc").originalFilename("findbyid_qc.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender).receivers(receivers).tags(tags)
|
||||||
|
.build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
|
||||||
|
stats.setStatisticsEnabled(true);
|
||||||
|
stats.clear();
|
||||||
|
|
||||||
|
documentRepository.findById(doc.getId());
|
||||||
|
|
||||||
|
assertThat(stats.getPrepareStatementCount())
|
||||||
|
.as("@EntityGraph(Document.full) must load sender+receivers+tags in ≤2 statements, not 4")
|
||||||
|
.isLessThanOrEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_withPageable_loadsSenderWithoutNPlusOne() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Maria").lastName("RaSender").build());
|
||||||
|
Tag tag = tagRepository.save(Tag.builder().name("RaTag2").build());
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("RaDoc2 " + i).originalFilename("radoc2_" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.tags(new HashSet<>(Set.of(tag)))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
|
||||||
|
stats.setStatisticsEnabled(true);
|
||||||
|
stats.clear();
|
||||||
|
|
||||||
|
documentRepository.findAll(PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "updatedAt")));
|
||||||
|
|
||||||
|
assertThat(stats.getPrepareStatementCount())
|
||||||
|
.as("@EntityGraph(Document.list) via findAll(Pageable) must not N+1 sender for 5 docs")
|
||||||
|
.isLessThanOrEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_withSpecOnly_appliesEntityGraphInAtMostFiveStatements() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Otto").lastName("SoSender").build());
|
||||||
|
Tag tag = tagRepository.save(Tag.builder().name("SoTag").build());
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("SoDoc " + i).originalFilename("sodoc_" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.tags(new HashSet<>(Set.of(tag)))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
|
||||||
|
stats.setStatisticsEnabled(true);
|
||||||
|
stats.clear();
|
||||||
|
|
||||||
|
Specification<Document> allDocs = (root, query, cb) -> null;
|
||||||
|
documentRepository.findAll(allDocs);
|
||||||
|
|
||||||
|
assertThat(stats.getPrepareStatementCount())
|
||||||
|
.as("@EntityGraph(Document.list) via findAll(Spec) must not N+1 sender for 5 docs")
|
||||||
|
.isLessThanOrEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── seeding helpers ─────────────────────────────────────────────────────
|
// ─── seeding helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Document uploaded(String title) {
|
private Document uploaded(String title) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.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})
|
||||||
@@ -67,7 +68,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")
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -76,7 +77,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")
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -92,7 +93,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")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -101,7 +102,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()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +116,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")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -133,7 +134,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")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -143,28 +144,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()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.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()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.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()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.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()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +175,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())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -183,7 +184,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())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -199,7 +200,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)
|
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -217,7 +218,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)
|
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -229,7 +230,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())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -238,7 +239,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())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.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());
|
||||||
@@ -247,7 +248,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())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"width\":0.005}"))
|
.content("{\"width\":0.005}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -256,7 +257,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())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"height\":0.005}"))
|
.content("{\"height\":0.005}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -265,7 +266,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())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"x\":1.1}"))
|
.content("{\"x\":1.1}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -276,7 +277,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")
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -294,7 +295,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")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -312,7 +313,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")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.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})
|
||||||
@@ -70,7 +71,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")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.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()));
|
||||||
@@ -79,7 +80,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")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -88,7 +89,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")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,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")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -116,7 +117,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")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,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")
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
@@ -136,7 +137,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")
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -151,7 +152,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")
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -166,7 +167,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")
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -175,7 +176,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)
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -187,7 +188,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)
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
@@ -199,7 +200,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)
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
@@ -208,14 +209,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))
|
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()))
|
||||||
.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))
|
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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})
|
||||||
@@ -143,7 +144,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createBlock_returns401_whenUnauthenticated() throws Exception {
|
void createBlock_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -152,7 +153,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void createBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
void createBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -164,7 +165,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)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -177,7 +178,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)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -192,7 +193,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
||||||
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -206,7 +207,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
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)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -217,7 +218,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateBlock_returns401_whenUnauthenticated() throws Exception {
|
void updateBlock_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -226,7 +227,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -243,7 +244,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)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -259,7 +260,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)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -272,7 +273,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)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -286,7 +287,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)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -297,7 +298,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)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -307,28 +308,28 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteBlock_returns401_whenUnauthenticated() throws Exception {
|
void deleteBlock_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.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))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.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))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.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))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +340,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))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +348,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reorderBlocks_returns401_whenUnauthenticated() throws Exception {
|
void reorderBlocks_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put(URL_REORDER)
|
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REORDER_JSON))
|
.content(REORDER_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -356,7 +357,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception {
|
void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(put(URL_REORDER)
|
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REORDER_JSON))
|
.content(REORDER_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -367,7 +368,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)
|
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REORDER_JSON))
|
.content(REORDER_JSON))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -434,7 +435,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))
|
DOC_ID, BLOCK_ID).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.reviewed").value(true));
|
.andExpect(jsonPath("$.reviewed").value(true));
|
||||||
}
|
}
|
||||||
@@ -445,14 +446,14 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.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))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +470,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))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray())
|
.andExpect(jsonPath("$").isArray())
|
||||||
.andExpect(jsonPath("$[0].reviewed").value(true))
|
.andExpect(jsonPath("$[0].reviewed").value(true))
|
||||||
@@ -483,7 +484,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))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray())
|
.andExpect(jsonPath("$").isArray())
|
||||||
.andExpect(jsonPath("$").isEmpty());
|
.andExpect(jsonPath("$").isEmpty());
|
||||||
@@ -494,7 +495,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))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.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})
|
||||||
@@ -130,7 +131,7 @@ class GeschichteControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void create_returns401_whenUnauthenticated() throws Exception {
|
void create_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/geschichten")
|
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"title\":\"x\"}"))
|
.content("{\"title\":\"x\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -139,7 +140,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")
|
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"title\":\"x\"}"))
|
.content("{\"title\":\"x\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -155,7 +156,7 @@ class GeschichteControllerTest {
|
|||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setTitle("New");
|
dto.setTitle("New");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/geschichten")
|
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -167,7 +168,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())
|
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -180,7 +181,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)
|
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"status\":\"PUBLISHED\"}"))
|
.content("{\"status\":\"PUBLISHED\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -192,7 +193,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()))
|
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +202,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))
|
mockMvc.perform(delete("/api/geschichten/{id}", id).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(geschichteService).delete(id);
|
verify(geschichteService).delete(id);
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class MassImportServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
||||||
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
||||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
|
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now());
|
||||||
ReflectionTestUtils.setField(service, "currentStatus", running);
|
ReflectionTestUtils.setField(service, "currentStatus", running);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.runImportAsync())
|
assertThatThrownBy(() -> service.runImportAsync())
|
||||||
@@ -154,9 +154,76 @@ class MassImportServiceTest {
|
|||||||
.build();
|
.build();
|
||||||
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
|
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
Optional<String> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
||||||
|
|
||||||
verify(documentService, never()).save(any());
|
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) ───────────
|
||||||
@@ -208,7 +275,7 @@ class MassImportServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
|
void importSingleDocument_returnsS3UploadFailed_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
|
||||||
Path tempFile = tempDir.resolve("fail.pdf");
|
Path tempFile = tempDir.resolve("fail.pdf");
|
||||||
Files.write(tempFile, "data".getBytes());
|
Files.write(tempFile, "data".getBytes());
|
||||||
|
|
||||||
@@ -216,10 +283,11 @@ 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));
|
||||||
|
|
||||||
service.importSingleDocument(
|
Optional<String> result = 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 ───────────────────────────────
|
||||||
@@ -325,8 +393,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"));
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
assertThat(result).isEqualTo(0);
|
assertThat(result.processed()).isEqualTo(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -335,8 +403,8 @@ class MassImportServiceTest {
|
|||||||
List.of("header"),
|
List.of("header"),
|
||||||
minimalCells("") // blank index
|
minimalCells("") // blank index
|
||||||
);
|
);
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
assertThat(result).isEqualTo(0);
|
assertThat(result.processed()).isEqualTo(0);
|
||||||
verify(documentService, never()).findByOriginalFilename(any());
|
verify(documentService, never()).findByOriginalFilename(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,9 +417,9 @@ class MassImportServiceTest {
|
|||||||
List.of("header"),
|
List.of("header"),
|
||||||
minimalCells("doc001") // no dot → appends ".pdf"
|
minimalCells("doc001") // no dot → appends ".pdf"
|
||||||
);
|
);
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(1);
|
assertThat(result.processed()).isEqualTo(1);
|
||||||
verify(documentService).findByOriginalFilename("doc001.pdf");
|
verify(documentService).findByOriginalFilename("doc001.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,9 +432,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
|
||||||
);
|
);
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(1);
|
assertThat(result.processed()).isEqualTo(1);
|
||||||
verify(documentService).findByOriginalFilename("doc002.pdf");
|
verify(documentService).findByOriginalFilename("doc002.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +593,67 @@ 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 ───────────────────────────────────
|
// ─── readOds — XXE security regression ───────────────────────────────────
|
||||||
|
|
||||||
// Security regression — do not remove.
|
// Security regression — do not remove.
|
||||||
@@ -621,4 +750,28 @@ class MassImportServiceTest {
|
|||||||
}
|
}
|
||||||
return destination.toFile();
|
return destination.toFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setupOneValidOneFakeImport(Path tempDir) throws Exception {
|
||||||
|
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||||
|
Files.write(tempDir.resolve("real.pdf"), pdfHeader);
|
||||||
|
Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
|
||||||
|
buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
|
||||||
|
Path xlsx = dir.resolve("import.xlsx");
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("Sheet1");
|
||||||
|
sheet.createRow(0).createCell(0).setCellValue("Index");
|
||||||
|
for (int i = 0; i < filenames.length; i++) {
|
||||||
|
sheet.createRow(i + 1).createCell(0).setCellValue(filenames[i]);
|
||||||
|
}
|
||||||
|
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||||
|
wb.write(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
import static org.springframework.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})
|
||||||
@@ -141,7 +142,7 @@ class NotificationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/notifications/read-all"))
|
mockMvc.perform(post("/api/notifications/read-all").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +152,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"))
|
mockMvc.perform(post("/api/notifications/read-all").with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(notificationService).markAllRead(USER_ID);
|
verify(notificationService).markAllRead(USER_ID);
|
||||||
@@ -161,7 +162,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"))
|
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +177,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"))
|
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +257,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")
|
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -275,7 +276,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")
|
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
|
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -337,7 +338,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"))
|
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf()))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.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})
|
||||||
@@ -66,7 +67,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)
|
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -80,7 +81,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)
|
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -127,7 +128,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")
|
mockMvc.perform(post("/api/ocr/batch").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -179,14 +180,14 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void triggerTraining_returns401_whenUnauthenticated() throws Exception {
|
void triggerTraining_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train"))
|
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||||
.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"))
|
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +197,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"))
|
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||||
.andExpect(status().isConflict());
|
.andExpect(status().isConflict());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +210,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"))
|
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||||
.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));
|
||||||
@@ -365,7 +366,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")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":null}"))
|
.content("{\"personId\":null}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -373,7 +374,7 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception {
|
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -382,7 +383,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")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -395,7 +396,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")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + unknownId + "\"}"))
|
.content("{\"personId\":\"" + unknownId + "\"}"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -410,7 +411,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")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + personId + "\"}"))
|
.content("{\"personId\":\"" + personId + "\"}"))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -426,7 +427,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")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + personId + "\"}"))
|
.content("{\"personId\":\"" + personId + "\"}"))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -442,7 +443,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")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + personId + "\"}"))
|
.content("{\"personId\":\"" + personId + "\"}"))
|
||||||
.andExpect(status().isAccepted());
|
.andExpect(status().isAccepted());
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.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})
|
||||||
@@ -217,7 +218,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createPerson_returns401_whenUnauthenticated() throws Exception {
|
void createPerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.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());
|
||||||
@@ -226,7 +227,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")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.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());
|
||||||
@@ -235,7 +236,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")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.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());
|
||||||
@@ -244,7 +245,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")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -253,7 +254,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")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.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());
|
||||||
@@ -265,7 +266,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")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.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())
|
||||||
@@ -278,7 +279,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")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.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())
|
||||||
@@ -293,7 +294,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")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.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());
|
||||||
@@ -307,7 +308,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")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.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())
|
||||||
@@ -318,7 +319,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())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.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());
|
||||||
@@ -327,7 +328,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())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.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());
|
||||||
@@ -336,7 +337,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())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -349,7 +350,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)
|
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||||
.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())
|
||||||
@@ -360,7 +361,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())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -369,7 +370,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())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -378,7 +379,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())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\" \"}"))
|
.content("{\"targetPersonId\":\" \"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -390,7 +391,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)
|
mockMvc.perform(post("/api/persons/{id}/merge", sourceId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\"" + targetId + "\"}"))
|
.content("{\"targetPersonId\":\"" + targetId + "\"}"))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
@@ -402,7 +403,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)
|
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||||
.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());
|
||||||
@@ -418,7 +419,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")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.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," +
|
||||||
@@ -436,7 +437,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)
|
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||||
.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());
|
||||||
@@ -447,7 +448,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)
|
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||||
.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());
|
||||||
@@ -458,7 +459,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")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.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());
|
||||||
@@ -467,7 +468,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())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.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());
|
||||||
@@ -476,7 +477,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())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -507,7 +508,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)
|
mockMvc.perform(post("/api/persons/{id}/aliases", personId).with(csrf())
|
||||||
.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())
|
||||||
@@ -517,7 +518,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())
|
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||||
.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());
|
||||||
@@ -531,7 +532,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))
|
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(personService).removeAlias(personId, aliasId);
|
verify(personService).removeAlias(personId, aliasId);
|
||||||
@@ -540,14 +541,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()))
|
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
|
||||||
.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())
|
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
|
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -556,7 +557,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())
|
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"de Gruyter\"}"))
|
.content("{\"lastName\":\"de Gruyter\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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})
|
||||||
@@ -67,7 +68,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)
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||||
.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());
|
||||||
@@ -76,14 +77,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()))
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf()))
|
||||||
.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)
|
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"familyMember\":true}"))
|
.content("{\"familyMember\":true}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -125,7 +126,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)
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||||
.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());
|
||||||
@@ -141,7 +142,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)
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||||
.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())
|
||||||
@@ -154,7 +155,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))
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,6 +29,7 @@ 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})
|
||||||
@@ -61,7 +62,7 @@ class TagControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateTag_returns401_whenUnauthenticated() throws Exception {
|
void updateTag_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"name\": \"New\"}"))
|
.content("{\"name\": \"New\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -70,7 +71,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())
|
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"name\": \"New\"}"))
|
.content("{\"name\": \"New\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -82,7 +83,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())
|
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"name\": \"New\"}"))
|
.content("{\"name\": \"New\"}"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -116,7 +117,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")
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -125,7 +126,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")
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -134,7 +135,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")
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -146,7 +147,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")
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -159,7 +160,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")
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetId\": \"" + targetId + "\"}"))
|
.content("{\"targetId\": \"" + targetId + "\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -171,21 +172,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"))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
||||||
.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"))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
||||||
.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"))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,21 +194,21 @@ class TagControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteTag_returns401_whenUnauthenticated() throws Exception {
|
void deleteTag_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.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()))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
||||||
.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()))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.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})
|
||||||
@@ -46,7 +47,7 @@ class AdminControllerTest {
|
|||||||
@WithMockUser(authorities = "ADMIN")
|
@WithMockUser(authorities = "ADMIN")
|
||||||
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
|
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
|
||||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
when(massImportService.getStatus()).thenReturn(status);
|
when(massImportService.getStatus()).thenReturn(status);
|
||||||
|
|
||||||
mockMvc.perform(get("/api/admin/import-status"))
|
mockMvc.perform(get("/api/admin/import-status"))
|
||||||
@@ -60,7 +61,7 @@ class AdminControllerTest {
|
|||||||
@WithMockUser(authorities = "ADMIN")
|
@WithMockUser(authorities = "ADMIN")
|
||||||
void importStatus_messageField_notPresentInApiResponse() throws Exception {
|
void importStatus_messageField_notPresentInApiResponse() throws Exception {
|
||||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
when(massImportService.getStatus()).thenReturn(status);
|
when(massImportService.getStatus()).thenReturn(status);
|
||||||
|
|
||||||
mockMvc.perform(get("/api/admin/import-status"))
|
mockMvc.perform(get("/api/admin/import-status"))
|
||||||
@@ -83,14 +84,14 @@ class AdminControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf()))
|
||||||
.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"))
|
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ class AdminControllerTest {
|
|||||||
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
|
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
|
||||||
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
|
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.count").value(1));
|
.andExpect(jsonPath("$.count").value(1));
|
||||||
}
|
}
|
||||||
@@ -109,14 +110,14 @@ class AdminControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
|
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf()))
|
||||||
.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"))
|
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ class AdminControllerTest {
|
|||||||
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
|
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
|
||||||
when(documentService.backfillFileHashes()).thenReturn(3);
|
when(documentService.backfillFileHashes()).thenReturn(3);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.count").value(3));
|
.andExpect(jsonPath("$.count").value(3));
|
||||||
}
|
}
|
||||||
@@ -134,14 +135,14 @@ class AdminControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generateThumbnails_returns401_whenUnauthenticated() throws Exception {
|
void generateThumbnails_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf()))
|
||||||
.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"))
|
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ class AdminControllerTest {
|
|||||||
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
|
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
|
||||||
when(thumbnailBackfillService.getStatus()).thenReturn(status);
|
when(thumbnailBackfillService.getStatus()).thenReturn(status);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf()))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
.andExpect(jsonPath("$.state").value("RUNNING"))
|
.andExpect(jsonPath("$.state").value("RUNNING"))
|
||||||
.andExpect(jsonPath("$.total").value(10));
|
.andExpect(jsonPath("$.total").value(10));
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.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})
|
||||||
@@ -117,7 +118,7 @@ class AuthControllerTest {
|
|||||||
req.setFirstName("Max");
|
req.setFirstName("Max");
|
||||||
req.setLastName("Muster");
|
req.setLastName("Muster");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/register")
|
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -134,7 +135,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")
|
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isConflict());
|
.andExpect(status().isConflict());
|
||||||
@@ -150,7 +151,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")
|
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -166,7 +167,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")
|
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -183,7 +184,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")
|
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.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})
|
||||||
@@ -103,7 +104,7 @@ class InviteControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createInvite_returns401_whenUnauthenticated() throws Exception {
|
void createInvite_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/invites")
|
mockMvc.perform(post("/api/invites").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -112,7 +113,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")
|
mockMvc.perform(post("/api/invites").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -142,7 +143,7 @@ class InviteControllerTest {
|
|||||||
req.setLabel("Für Familie");
|
req.setLabel("Für Familie");
|
||||||
req.setMaxUses(1);
|
req.setMaxUses(1);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/invites")
|
mockMvc.perform(post("/api/invites").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -164,7 +165,7 @@ class InviteControllerTest {
|
|||||||
.thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345"));
|
.thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345"));
|
||||||
|
|
||||||
String body = "{\"groupIds\":[\"" + groupId + "\"]}";
|
String body = "{\"groupIds\":[\"" + groupId + "\"]}";
|
||||||
mockMvc.perform(post("/api/invites")
|
mockMvc.perform(post("/api/invites").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -178,14 +179,14 @@ class InviteControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void revokeInvite_returns401_whenUnauthenticated() throws Exception {
|
void revokeInvite_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.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()))
|
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +195,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))
|
mockMvc.perform(delete("/api/invites/" + id).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(inviteService).revokeInvite(id);
|
verify(inviteService).revokeInvite(id);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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)
|
||||||
@@ -36,8 +37,10 @@ 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())
|
||||||
@@ -176,6 +179,27 @@ class PasswordResetServiceTest {
|
|||||||
verify(mailSender).send(any(SimpleMailMessage.class));
|
verify(mailSender).send(any(SimpleMailMessage.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetPassword_revokes_all_sessions_after_password_reset() {
|
||||||
|
AppUser user = makeUser("user@example.com");
|
||||||
|
PasswordResetToken token = PasswordResetToken.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.token("validtoken123")
|
||||||
|
.user(user)
|
||||||
|
.expiresAt(LocalDateTime.now().plusHours(1))
|
||||||
|
.used(false)
|
||||||
|
.build();
|
||||||
|
when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token));
|
||||||
|
when(passwordEncoder.encode(any())).thenReturn("hashed");
|
||||||
|
|
||||||
|
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||||
|
req.setToken("validtoken123");
|
||||||
|
req.setNewPassword("newpass");
|
||||||
|
service.resetPassword(req);
|
||||||
|
|
||||||
|
verify(authService).revokeAllSessions("user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── cleanupExpiredTokens ─────────────────────────────────────────────────
|
// ─── cleanupExpiredTokens ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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;
|
||||||
@@ -10,6 +12,7 @@ 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;
|
||||||
@@ -17,6 +20,8 @@ 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;
|
||||||
@@ -24,6 +29,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
import static org.springframework.test.web.servlet.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})
|
||||||
@@ -32,6 +38,8 @@ 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 ────────────────────────────────────────────────────────
|
||||||
@@ -83,7 +91,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")
|
mockMvc.perform(post("/api/users").with(csrf())
|
||||||
.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());
|
||||||
@@ -92,7 +100,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")
|
mockMvc.perform(post("/api/users").with(csrf())
|
||||||
.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());
|
||||||
@@ -101,7 +109,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")
|
mockMvc.perform(post("/api/users").with(csrf())
|
||||||
.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());
|
||||||
@@ -112,7 +120,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")
|
mockMvc.perform(post("/api/users").with(csrf())
|
||||||
.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());
|
||||||
@@ -121,7 +129,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())
|
mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -130,7 +138,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()))
|
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +146,7 @@ class UserControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createUser_returns401_whenUnauthenticated() throws Exception {
|
void createUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/users")
|
mockMvc.perform(post("/api/users").with(csrf())
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.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());
|
||||||
@@ -146,7 +154,7 @@ class UserControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -154,7 +162,92 @@ class UserControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,23 @@ 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
|
||||||
@@ -142,8 +159,14 @@ 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:/root/.cache
|
- ocr-cache:/app/cache # HuggingFace / ketos cache — prevents re-downloads on recreate (HF_HOME)
|
||||||
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"
|
||||||
@@ -161,6 +184,17 @@ 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}
|
||||||
@@ -242,6 +276,9 @@ 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:
|
||||||
|
|||||||
@@ -71,6 +71,23 @@ 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.
|
||||||
@@ -87,8 +104,14 @@ services:
|
|||||||
memswap_limit: 12g
|
memswap_limit: 12g
|
||||||
volumes:
|
volumes:
|
||||||
- ocr_models:/app/models
|
- ocr_models:/app/models
|
||||||
- ocr_cache:/root/.cache # Hugging Face / ketos model download cache — prevents re-downloads on container recreate
|
- ocr_cache:/app/cache # HuggingFace / ketos cache — prevents re-downloads on recreate (HF_HOME)
|
||||||
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"
|
||||||
@@ -106,6 +129,17 @@ 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:
|
||||||
@@ -194,6 +228,9 @@ services:
|
|||||||
API_INTERNAL_URL: http://backend:8080
|
API_INTERNAL_URL: http://backend:8080
|
||||||
# Vite dev proxy forwards /api from browser to the backend container
|
# Vite dev proxy forwards /api from browser to the backend container
|
||||||
API_PROXY_TARGET: http://backend:8080
|
API_PROXY_TARGET: http://backend:8080
|
||||||
|
# Upload size limit for adapter-node (production target). Not enforced by Vite dev server
|
||||||
|
# but kept here to match docker-compose.prod.yml and prevent config drift.
|
||||||
|
BODY_SIZE_LIMIT: 50M
|
||||||
ports:
|
ports:
|
||||||
- "${PORT_FRONTEND}:5173"
|
- "${PORT_FRONTEND}:5173"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
|||||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. |
|
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). |
|
||||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
| `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 Base64-encoded Basic Auth token stored in an `httpOnly`, `SameSite=strict` cookie (`auth_token`, maxAge=86400 s). CSRF protection is disabled because this cookie configuration structurally prevents cross-origin credential theft. See [docs/security-guide.md](security-guide.md) for the full security reference.
|
Sessions use a Spring Session JDBC-backed cookie (`fa_session`, `httpOnly`, `SameSite=strict`, maxAge=86400 s). CSRF protection uses the double-submit cookie pattern: Spring Security sets an `XSRF-TOKEN` cookie (readable by JS); SvelteKit's `handleFetch` injects the value as `X-XSRF-TOKEN` on every mutating request; a missing or mismatched token returns `403 CSRF_TOKEN_MISSING`. See [ADR-022](adr/022-csrf-session-revocation-rate-limiting.md) and [docs/security-guide.md](security-guide.md) for the full security reference.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -140,6 +141,8 @@ 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`)
|
### Observability stack (`docker-compose.observability.yml`)
|
||||||
|
|
||||||
@@ -554,3 +557,44 @@ 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 1–2 GB, one-time cost).
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ _See also [Annotation](#annotation-documentannotation)._
|
|||||||
|
|
||||||
**Mass import** — an asynchronous batch process (`MassImportService`) that reads an Excel or ODS file and creates `Person`s, `Tag`s, and `PLACEHOLDER` `Document`s in one shot. Only one import can run at a time (`IMPORT_ALREADY_RUNNING` error if attempted concurrently).
|
**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)._
|
||||||
|
|
||||||
|
|||||||
94
docs/adr/019-container-hardening-baseline.md
Normal file
94
docs/adr/019-container-hardening-baseline.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# 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.
|
||||||
94
docs/adr/020-stateful-auth-via-spring-session-jdbc.md
Normal file
94
docs/adr/020-stateful-auth-via-spring-session-jdbc.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# ADR-020 — Stateful Authentication via Spring Session JDBC
|
||||||
|
|
||||||
|
**Date:** 2026-05-17
|
||||||
|
**Status:** Accepted
|
||||||
|
**Issue:** #523
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
PR #521 (closing #520) introduced `AuthTokenCookieFilter` to unblock a production deploy.
|
||||||
|
The filter promotes an `auth_token` cookie — which contains the full HTTP Basic credential
|
||||||
|
(`Basic <base64(email:password)>`) — to an `Authorization` header so browser-direct `/api/*`
|
||||||
|
calls authenticate correctly behind Caddy.
|
||||||
|
|
||||||
|
This model has three concrete problems:
|
||||||
|
|
||||||
|
1. **Cookie = credential.** A stolen `auth_token` cookie leaks the user's password in
|
||||||
|
base64-encoded plaintext. No decode step is needed; the cookie value is directly usable
|
||||||
|
as a credential forever.
|
||||||
|
2. **No server-side revocation.** Logout deletes the local cookie but the credential
|
||||||
|
remains valid until the 24 h `Max-Age` elapses. An attacker who copied the cookie before
|
||||||
|
logout retains access.
|
||||||
|
3. **No audit signal.** There is no server-side record of login or logout events. Observability
|
||||||
|
and compliance tooling cannot reconstruct "who was logged in when".
|
||||||
|
|
||||||
|
Additionally, Nora flagged that `url.protocol === 'https:'` in `login/+page.server.ts` is
|
||||||
|
incorrect behind Caddy: SvelteKit sees `http`, so `Secure=false` was set on the credential
|
||||||
|
cookie in production, transmitting it in cleartext from Caddy to the browser on any network
|
||||||
|
path without TLS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Replace the `auth_token` / `AuthTokenCookieFilter` model with **Spring Session JDBC**:
|
||||||
|
|
||||||
|
- A `POST /api/auth/login` endpoint in a new `auth` package authenticates with `email +
|
||||||
|
password`, creates a server-side session record in PostgreSQL, and returns the `AppUser`
|
||||||
|
JSON in the response body.
|
||||||
|
- The response sets an **opaque** `fa_session` cookie (`HttpOnly`, `SameSite=Strict`,
|
||||||
|
`Secure` in non-dev profiles, `Max-Age=28800` — 8 h idle timeout) that contains only the
|
||||||
|
session ID, never a credential.
|
||||||
|
- A `POST /api/auth/logout` endpoint invalidates the session record immediately. Subsequent
|
||||||
|
requests carrying the same cookie return 401.
|
||||||
|
- `AuthTokenCookieFilter` is deleted in the same PR. No transitional coexistence period.
|
||||||
|
- Cookie name `fa_session` (not the default `SESSION`) minimises framework fingerprinting.
|
||||||
|
|
||||||
|
Session storage uses the canonical `spring_session` / `spring_session_attributes` tables,
|
||||||
|
re-introduced via `V67__recreate_spring_session_tables.sql` (dropped by V2 when the
|
||||||
|
dependency was previously removed as unused).
|
||||||
|
|
||||||
|
**Idle timeout:** 8 h (`MaxInactiveIntervalInSeconds = 28800`). No 24 h absolute cap is
|
||||||
|
implemented in Phase 1 — the 8 h idle bound contains the risk to one workday. A weekend-long
|
||||||
|
active session is acceptable given the family-archive threat model. The absolute cap and
|
||||||
|
additional revocation paths (password-change, admin force-logout) land in Phase 2 (#524).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### Stay on Basic cookie + add a server-side revocation table
|
||||||
|
|
||||||
|
Keeps the credential-in-cookie problem. Implementing a revocation table would re-invent
|
||||||
|
Spring Session badly — we'd write bespoke session storage that already exists and is
|
||||||
|
well-tested upstream.
|
||||||
|
|
||||||
|
### JWT (stateless)
|
||||||
|
|
||||||
|
Opaque revocation is simpler than JWT revocation (token introspection or short-lived tokens
|
||||||
|
+ refresh). The cluster is single-node; session affinity is not a constraint. Stateless tokens
|
||||||
|
buy complexity without benefit here. JWKS infrastructure and refresh-token rotation are
|
||||||
|
unnecessary for a family archive with < 50 concurrent users.
|
||||||
|
|
||||||
|
### Keep `auth_token` cookie but add `AuthTokenCookieFilter` improvements
|
||||||
|
|
||||||
|
The root problem is that the cookie contains the credential. No amount of filter hardening
|
||||||
|
fixes that. Nora's P1 flag stands until the credential leaves the cookie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **One breaking deploy.** All existing sessions (the `auth_token` cookies) become inert
|
||||||
|
on the next request after the deploy. The SvelteKit `handleAuth` hook redirects to
|
||||||
|
`/login?reason=expired`; a banner renders. Users re-login. No data loss.
|
||||||
|
- **~2 KB per active session** in PostgreSQL (`spring_session_attributes` stores the
|
||||||
|
serialised `SecurityContext`). With < 50 family members, this is immaterial.
|
||||||
|
- **Session cleanup task** runs on the default Spring Session JDBC schedule (every 10 min).
|
||||||
|
No custom job needed.
|
||||||
|
- **Caddy / infrastructure unchanged.** `forward-headers-strategy: native` already ensures
|
||||||
|
`Secure` cookies work correctly behind the reverse proxy.
|
||||||
|
- **Dev profile:** `application-dev.yaml` sets `secure: false` on the session cookie so
|
||||||
|
local HTTP dev (port 5173 → 8080) works without TLS.
|
||||||
68
docs/adr/021-tmpdir-persistent-volume-staging.md
Normal file
68
docs/adr/021-tmpdir-persistent-volume-staging.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR-021 — Route Surya model-download staging to the persistent cache volume via TMPDIR
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-05-18
|
||||||
|
**Issue:** #614
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
After the container hardening baseline (ADR-019), the OCR service runs with `read_only: true` and a 512 MB `/tmp` tmpfs. The tmpfs was sized for training-ZIP extraction (typically 20–50 images, well under 100 MB).
|
||||||
|
|
||||||
|
Surya's `download_directory()` (surya ≥ 0.6, `surya/common/s3.py`) stages every model file through `tempfile.TemporaryDirectory()` before moving it to the final cache location. `TemporaryDirectory()` honours `$TMPDIR` and falls back to `/tmp`. The `text_recognition` model is 1.34 GB; future Surya models will be in the same range. This blows the 512 MB budget at ~510 MB with `OSError: [Errno 28] No space left on device`.
|
||||||
|
|
||||||
|
The host has 1.8 TB free on the disk that backs `/app/cache`. The failure is a routing problem, not a capacity problem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Set `TMPDIR=/app/cache/.tmp` in the OCR container so all `tempfile` staging goes to the persistent SSD-backed cache volume.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml / docker-compose.prod.yml — ocr-service.environment
|
||||||
|
TMPDIR: /app/cache/.tmp
|
||||||
|
```
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# ocr-service/Dockerfile — default for bare docker-run usage
|
||||||
|
ENV TMPDIR=/app/cache/.tmp
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ocr-service/entrypoint.sh — idempotent directory bootstrap
|
||||||
|
mkdir -p "${TMPDIR:-/tmp}"
|
||||||
|
find "${TMPDIR:-/tmp}" -mindepth 1 -mtime +1 -delete 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
A one-shot `ocr-volume-init` service in both compose files runs before `ocr-service` to `chown -R 1000:1000` the volumes and `mkdir -p /app/cache/.tmp`. This replaces the manual `docker run --rm alpine chown` step performed on 2026-05-18 and makes fresh-volume correctness a permanent infrastructure-as-code guarantee.
|
||||||
|
|
||||||
|
The `/tmp` tmpfs remains at 512 MB and continues to serve training-ZIP extraction and transient PDF buffers — its original purpose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- Surya model downloads complete: 1.34 GB fits on the SSD, not in 512 MB of RAM.
|
||||||
|
- `shutil.move()` from staging → cache becomes a same-filesystem `rename(2)` — atomic and near-free.
|
||||||
|
- Volume ownership is now automated; no manual `docker run --rm alpine chown` on redeploy.
|
||||||
|
- `/tmp` retains its small 512 MB DoS cap for attacker-influenceable training endpoints (post-auth only, behind `X-Training-Token`).
|
||||||
|
- ZIP Slip protection in `_validate_zip_entry()` is unaffected — it uses `os.path.realpath()` anchored to the extraction directory regardless of where that directory lives.
|
||||||
|
|
||||||
|
**Negative / Trade-offs**
|
||||||
|
|
||||||
|
- If the container is `docker kill`ed mid-download, partial files persist in `/app/cache/.tmp` across container restarts. Mitigated by the `find -mtime +1 -delete` in `entrypoint.sh` — orphans older than one day are removed on startup.
|
||||||
|
- `TMPDIR` pointing inside a volume mount is non-obvious. Any future move of `/app/cache` to a different storage tier must revisit this setting. This ADR is the load-bearing reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**Approach B — Enlarge `/tmp` to 4 GB**
|
||||||
|
One-line change. Discarded because: (1) 4 GB tmpfs counts against the cgroup `mem_limit`; on CX32 hosts with `OCR_MEM_LIMIT=6g` the combined Surya resident set + tmpfs would trigger OOMKill on cold start; (2) staging GB-scale model files through RAM is using the wrong storage tier; (3) any future model larger than 4 GB requires another bump.
|
||||||
|
|
||||||
|
**Approach C — Both TMPDIR redirect and enlarged /tmp**
|
||||||
|
Belt-and-suspenders: Approach A + 1 GB tmpfs. Discarded in favour of the cleaner Approach A. The defence-in-depth benefit does not outweigh the extra compose churn; the 512 MB cap on `/tmp` is intentional.
|
||||||
115
docs/adr/022-csrf-session-revocation-rate-limiting.md
Normal file
115
docs/adr/022-csrf-session-revocation-rate-limiting.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# ADR-022 — CSRF Protection, Session Revocation, and Login Rate Limiting
|
||||||
|
|
||||||
|
**Date:** 2026-05-18
|
||||||
|
**Status:** Accepted
|
||||||
|
**Issue:** #524
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-020 established stateful authentication via Spring Session JDBC. Three
|
||||||
|
follow-on security concerns were left open:
|
||||||
|
|
||||||
|
1. **CSRF.** State-changing API calls from the SvelteKit frontend use session
|
||||||
|
cookies. Without CSRF protection an attacker can forge cross-origin requests
|
||||||
|
that carry the victim's session cookie.
|
||||||
|
|
||||||
|
2. **Session revocation.** A user who changes or resets their password may still
|
||||||
|
have other active sessions (other browsers, shared devices). Those sessions
|
||||||
|
should be invalidated so the credential change takes full effect immediately.
|
||||||
|
|
||||||
|
3. **Login rate limiting.** The login endpoint accepts arbitrary email/password
|
||||||
|
pairs. Without throttling it is vulnerable to brute-force and credential-
|
||||||
|
stuffing attacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. CSRF — double-submit cookie pattern
|
||||||
|
|
||||||
|
`SecurityConfig` enables `CookieCsrfTokenRepository.withHttpOnlyFalse()`:
|
||||||
|
|
||||||
|
- The backend sets an `XSRF-TOKEN` cookie (readable by JavaScript) on every
|
||||||
|
response.
|
||||||
|
- All state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`) must include
|
||||||
|
an `X-XSRF-TOKEN` request header whose value matches the cookie.
|
||||||
|
- `CsrfTokenRequestAttributeHandler` is used (non-XOR mode) — correct for
|
||||||
|
SPAs where token deferred loading would otherwise corrupt values.
|
||||||
|
- SvelteKit's `handleFetch` hook injects the header and mirrors the cookie for
|
||||||
|
every mutating API call.
|
||||||
|
- CSRF validation failures return HTTP 403 with JSON body
|
||||||
|
`{"code": "CSRF_TOKEN_MISSING"}` via a custom `AccessDeniedHandler`.
|
||||||
|
|
||||||
|
Login (`POST /api/auth/login`), forgot-password, and reset-password are
|
||||||
|
**not** CSRF-exempt — the XSRF-TOKEN cookie is set on the first GET to the
|
||||||
|
login page, so the double-submit requirement is satisfiable from the browser.
|
||||||
|
|
||||||
|
### 2. Session revocation
|
||||||
|
|
||||||
|
`AuthService` gains two methods backed by `JdbcIndexedSessionRepository`:
|
||||||
|
|
||||||
|
- `revokeOtherSessions(currentSessionId, principal)` — deletes all sessions
|
||||||
|
for a principal **except** the caller's current session. Called on password
|
||||||
|
change so the user stays logged in on the current device.
|
||||||
|
- `revokeAllSessions(principal)` — deletes every session for a principal.
|
||||||
|
Called on password reset (unauthenticated flow) so no prior sessions survive.
|
||||||
|
|
||||||
|
Both methods are no-ops when `sessionRepository` is `null` (unit-test
|
||||||
|
contexts that do not load Spring Session).
|
||||||
|
|
||||||
|
### 3. Login rate limiting — in-memory token bucket
|
||||||
|
|
||||||
|
`LoginRateLimiter` (Bucket4j + Caffeine) enforces two independent limits:
|
||||||
|
|
||||||
|
| Bucket | Limit | Window | Key |
|
||||||
|
|--------|-------|--------|-----|
|
||||||
|
| Per IP + email | 10 attempts | 15 min | `ip:email` |
|
||||||
|
| Per IP (all emails) | 20 attempts | 15 min | `ip` |
|
||||||
|
|
||||||
|
On each login attempt both buckets are checked **sequentially**:
|
||||||
|
1. Consume from the `ip:email` bucket first.
|
||||||
|
2. If the IP-level bucket is exhausted, **refund** the `ip:email` token.
|
||||||
|
|
||||||
|
The refund prevents IP-level blocking from silently consuming per-email quota:
|
||||||
|
without it, 20 blocked attempts for `target@example.com` from a single IP
|
||||||
|
(caused by another email exhausting the IP bucket) would drain all 10 of
|
||||||
|
`target@`'s tokens.
|
||||||
|
|
||||||
|
On a successful login both buckets are invalidated for that `(ip, email)` pair
|
||||||
|
so a legitimately authenticated user regains the full window immediately.
|
||||||
|
|
||||||
|
Rate-limit violations are audited as `LOGIN_RATE_LIMITED` events.
|
||||||
|
|
||||||
|
The cache is **node-local** (in-memory). In a multi-replica deployment the
|
||||||
|
effective rate limit is multiplied by the replica count. This is acceptable for
|
||||||
|
the current single-VPS production setup and is noted with a comment in the
|
||||||
|
source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **CSRF:** All SvelteKit API calls must supply `X-XSRF-TOKEN`. Bare `curl`
|
||||||
|
calls or non-browser clients must obtain and pass the token manually.
|
||||||
|
Integration tests use `.with(csrf())` from `spring-security-test`.
|
||||||
|
- **Session revocation:** Requires `JdbcIndexedSessionRepository` to be wired
|
||||||
|
(Spring Session JDBC dependency). Unit tests inject `null` and verify the
|
||||||
|
no-op path.
|
||||||
|
- **Rate limiting:** False positives are possible if many users share a NAT/VPN
|
||||||
|
IP. The per-IP limit (20) is intentionally loose to reduce collateral
|
||||||
|
blocking; the per-IP+email limit (10) is the primary defence.
|
||||||
|
- `ObjectMapper` in the CSRF `AccessDeniedHandler` uses a static instance
|
||||||
|
because `@WebMvcTest` slices exclude `JacksonAutoConfiguration`. The response
|
||||||
|
only serialises a fixed String key (`"code"`) so naming strategy and custom
|
||||||
|
modules are irrelevant.
|
||||||
|
- IP extraction uses `HttpServletRequest.getRemoteAddr()`. In deployments behind
|
||||||
|
a reverse proxy the `X-Forwarded-For` header is not trusted — doing so would
|
||||||
|
let clients spoof their IP and trivially bypass the per-IP limit. Trusting
|
||||||
|
proxy headers requires separate work (e.g. Spring's `ForwardedHeaderFilter`
|
||||||
|
with an allowlist of trusted proxy addresses).
|
||||||
|
- IPv6 and IPv4-mapped addresses (e.g. `::ffff:1.2.3.4`) are not normalised to
|
||||||
|
a canonical form. An attacker with access to multiple IPv6 addresses could
|
||||||
|
rotate addresses to bypass the per-IP bucket. This is a known limitation of
|
||||||
|
address-based rate limiting and is acceptable for the current deployment.
|
||||||
110
docs/adr/022-eager-to-lazy-fetch-strategy.md
Normal file
110
docs/adr/022-eager-to-lazy-fetch-strategy.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# ADR-022 — EAGER→LAZY Fetch Strategy for Document Collections
|
||||||
|
|
||||||
|
**Date:** 2026-05-18
|
||||||
|
**Status:** Accepted
|
||||||
|
**Issue:** #467
|
||||||
|
**PR:** #622
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A pre-production query audit of 24 HTTP requests to the document list and detail endpoints
|
||||||
|
produced **2,733 SQL statements** — primarily N+1 queries caused by `FetchType.EAGER` on
|
||||||
|
`Document.receivers`, `Document.tags`, `Document.trainingLabels`, and `Document.sender`.
|
||||||
|
|
||||||
|
With EAGER fetch, every `Document` loaded by any repository method immediately triggers
|
||||||
|
additional `SELECT` statements for each associated collection, regardless of whether the
|
||||||
|
caller needs those associations. For a list of 100 documents, this means up to 400 extra
|
||||||
|
queries for `receivers` alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Switch all four associations to `FetchType.LAZY` and use a two-tier strategy to load exactly
|
||||||
|
what each code path needs:
|
||||||
|
|
||||||
|
**Tier 1 — Named entity graphs on `Document` + `@EntityGraph` overrides on `DocumentRepository`:**
|
||||||
|
|
||||||
|
- `Document.full` — loads `sender`, `receivers`, `tags` — used by `findById` (detail view)
|
||||||
|
- `Document.list` — loads `sender`, `tags` — used by `findAll(Spec, Pageable)`,
|
||||||
|
`findAll(Spec)`, and `findAll(Pageable)` (list/search/dashboard paths)
|
||||||
|
|
||||||
|
Each repository method that is called from a hot code path has an `@EntityGraph` override
|
||||||
|
that declares exactly which associations to JOIN-fetch, collapsing N+1 into 1–2 queries.
|
||||||
|
|
||||||
|
**Tier 2 — `@BatchSize(50)` fallback on all four associations:**
|
||||||
|
|
||||||
|
For any lazy access path not covered by an entity graph (e.g., a future ad-hoc query or an
|
||||||
|
in-memory sort that touches `trainingLabels`), Hibernate batches the secondary `SELECT` to
|
||||||
|
at most one statement per 50 entities instead of one per entity.
|
||||||
|
|
||||||
|
**Session lifetime for post-return lazy access:**
|
||||||
|
|
||||||
|
`getDocumentById` and `getRecentActivity` return entities to callers that may access lazy
|
||||||
|
associations after the repository call returns. Both methods are annotated
|
||||||
|
`@Transactional(readOnly = true)` to keep the Hibernate session open until the service method
|
||||||
|
returns, making those post-return accesses safe.
|
||||||
|
|
||||||
|
This is an intentional exception to the project convention that read methods are not annotated
|
||||||
|
(see `CLAUDE.md §Services`). The convention remains correct for all other read methods; this
|
||||||
|
exception applies only to methods that serve lazy-initialized associations to their callers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### `@BatchSize`-only (no entity graphs)
|
||||||
|
|
||||||
|
`@BatchSize(50)` on all associations would eliminate the worst N+1 cases (100 documents → 2
|
||||||
|
batch queries instead of 100 individual queries) without requiring repository overrides. Simpler
|
||||||
|
to maintain — no named graph definitions, no per-method overrides.
|
||||||
|
|
||||||
|
Rejected because batch loading is best-effort: it depends on what Hibernate happens to find in
|
||||||
|
the first-level cache and produces a variable number of statements. Entity graphs produce a
|
||||||
|
deterministic, verifiable statement count that can be asserted in tests. The query-count test
|
||||||
|
suite (`DocumentRepositoryTest`) validates the exact statement bounds on every CI run.
|
||||||
|
|
||||||
|
### Single unified entity graph (`Document.full` everywhere)
|
||||||
|
|
||||||
|
Loading `receivers` on every list query is wasteful — the document list view only needs
|
||||||
|
`sender` and `tags`. `receivers` is a `@ManyToMany` collection that, when JOIN-fetched together
|
||||||
|
with `tags`, forces Hibernate to split into two queries anyway (to avoid Cartesian product).
|
||||||
|
Using a single graph on list paths would load data the UI does not display.
|
||||||
|
|
||||||
|
Rejected in favour of two graphs with distinct scopes: `Document.list` for list paths
|
||||||
|
(sender + tags), `Document.full` for detail paths (sender + receivers + tags).
|
||||||
|
|
||||||
|
### `@Transactional` on the Spring Data repository methods
|
||||||
|
|
||||||
|
Spring Data allows `@Transactional` on repository interfaces directly. This would keep the
|
||||||
|
session open for all calls to those methods without touching the service layer.
|
||||||
|
|
||||||
|
Rejected because the transaction boundary belongs at the service layer — repositories should
|
||||||
|
not own transaction lifecycle. The service methods are the natural scope for "keep the session
|
||||||
|
open long enough for the caller to use the result."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Query count reduced from ~2,733 to ≤10 statements per 24 HTTP requests** — verified by
|
||||||
|
`DocumentRepositoryTest` query-count assertions and `DocumentLazyLoadingTest` smoke tests.
|
||||||
|
- **Read methods that return lazily-initialized entities must carry `@Transactional(readOnly = true)`.**
|
||||||
|
Any future service method that loads a `Document` and returns it to a caller that accesses
|
||||||
|
lazy associations must follow this pattern. Removing the annotation causes
|
||||||
|
`LazyInitializationException` in production.
|
||||||
|
- **New lazy code paths need an entity graph or `@BatchSize` review.** Any new
|
||||||
|
`DocumentRepository` method added to a hot code path should be assessed for N+1 risk and
|
||||||
|
given an `@EntityGraph` override if warranted.
|
||||||
|
- **`@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})` required on serialized lazy-proxy entities.**
|
||||||
|
`Person` and `Tag` carry this annotation to prevent Jackson from attempting to serialize
|
||||||
|
Hibernate proxy internals when the association is not initialized. Any new entity that is
|
||||||
|
used as a lazy association and serialized directly (without a DTO) needs the same annotation.
|
||||||
|
- **Named graph strings in `Document.java` and `DocumentRepository.java` must stay in sync.**
|
||||||
|
The `@NamedEntityGraph(name = "Document.full")` / `@NamedEntityGraph(name = "Document.list")`
|
||||||
|
definitions on `Document` are referenced by string in every `@EntityGraph(value = "...")` on
|
||||||
|
`DocumentRepository`. If the names diverge (e.g. a graph is renamed in one place but not the
|
||||||
|
other), Spring Data throws at application startup. Always update both files together when
|
||||||
|
renaming or restructuring a named graph.
|
||||||
@@ -7,15 +7,29 @@ Container(frontend, "Web Frontend", "SvelteKit")
|
|||||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||||
|
|
||||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||||
Component(secFilter, "Security Filter Chain", "Spring Security", "Enforces authentication on all requests. Parses Basic Auth header and constructs an Authentication token; delegates credential validation to DaoAuthenticationProvider via BCrypt. Permits password-reset, invite, and register endpoints without authentication.")
|
Component(authCtrl, "AuthSessionController", "@RestController org.raddatz.familienarchiv.auth", "POST /api/auth/login validates credentials, rotates the session ID via SessionAuthenticationStrategy (CWE-384 defense), attaches the SecurityContext to the new session. POST /api/auth/logout invalidates the session unconditionally, then best-effort audits.")
|
||||||
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks user's granted authorities against the required permission. Throws 401/403 if denied.")
|
Component(authSvc, "AuthService", "@Service org.raddatz.familienarchiv.auth", "Delegates credential validation to AuthenticationManager (DaoAuthenticationProvider — timing-equalised via dummy BCrypt on misses). Emits LOGIN_SUCCESS / LOGIN_FAILED / LOGOUT audit entries without ever logging the password attempt.")
|
||||||
Component(secConf, "SecurityConfig", "Spring @Configuration", "Configures filter chain: all routes require authentication, CSRF disabled, BCrypt password encoder, DaoAuthenticationProvider with CustomUserDetailsService.")
|
Component(secFilter, "Security Filter Chain", "Spring Security", "Permits /api/auth/login, /api/auth/forgot-password, /api/auth/reset-password, /api/auth/invite/**, /api/auth/register; everything else requires an authenticated session. Returns 401 (not 302) on missing/expired session. CSRF enabled: double-submit cookie pattern (CookieCsrfTokenRepository.withHttpOnlyFalse + CsrfTokenRequestAttributeHandler). Custom AccessDeniedHandler returns JSON {\"code\":\"CSRF_TOKEN_MISSING\"}.")
|
||||||
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects. Logs unknown permissions.")
|
Component(sessionRepo, "Spring Session JDBC", "spring-boot-starter-session-jdbc", "Persists sessions in spring_session / spring_session_attributes (Flyway V67). 8-hour idle timeout. Cookie name fa_session, SameSite=Strict, HttpOnly, Secure behind Caddy. Indexes the session by Principal name for revocation.")
|
||||||
|
Component(permAspect, "PermissionAspect", "Spring AOP", "Intercepts methods annotated with @RequirePermission. Checks the authenticated user's granted authorities against the required permission. Throws 401/403 if denied.")
|
||||||
|
Component(secConf, "SecurityConfig", "Spring @Configuration", "Wires the filter chain, BCryptPasswordEncoder, DaoAuthenticationProvider, AuthenticationManager, and the ChangeSessionIdAuthenticationStrategy bean used by AuthSessionController.")
|
||||||
|
Component(userDetails, "CustomUserDetailsService", "Spring Security UserDetailsService", "Loads AppUser by email from DB. Converts group permissions to Spring GrantedAuthority objects.")
|
||||||
|
Component(rateLimiter, "LoginRateLimiter", "@Component org.raddatz.familienarchiv.auth", "Dual Bucket4j/Caffeine in-memory rate limiting: per ip:email bucket and per ip bucket. checkAndConsume() throws TOO_MANY_LOGIN_ATTEMPTS (429) when either bucket is exhausted. invalidateOnSuccess() resets both buckets on successful login. Buckets expire after idle windowMinutes.")
|
||||||
|
Component(rateLimitProps, "RateLimitProperties", "@ConfigurationProperties(\"rate-limit.login\") org.raddatz.familienarchiv.auth", "Externalized config for login rate limiting: maxAttemptsPerIpEmail (default 10), maxAttemptsPerIp (default 20), windowMinutes (default 15). Bound from application.yaml rate-limit.login block.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Rel(frontend, secFilter, "All requests", "HTTP / Basic Auth header")
|
Rel(frontend, authCtrl, "POST /api/auth/login + /logout", "HTTPS, JSON")
|
||||||
|
Rel(frontend, secFilter, "All other API calls", "HTTPS + fa_session cookie + X-XSRF-TOKEN header")
|
||||||
|
Rel(authCtrl, authSvc, "Validate creds + audit")
|
||||||
|
Rel(authCtrl, sessionRepo, "getSession() / invalidate()")
|
||||||
|
Rel(authSvc, userDetails, "Authenticates via AuthenticationManager")
|
||||||
|
Rel(authSvc, rateLimiter, "checkAndConsume() / invalidateOnSuccess()")
|
||||||
|
Rel(authSvc, sessionRepo, "revokeOtherSessions() / revokeAllSessions()")
|
||||||
|
Rel(rateLimiter, rateLimitProps, "Reads config")
|
||||||
|
Rel(secFilter, sessionRepo, "Resolves session by fa_session cookie")
|
||||||
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
|
Rel(secFilter, permAspect, "Authenticated requests reach guarded service methods")
|
||||||
Rel(secConf, userDetails, "Wires as UserDetailsService")
|
Rel(secConf, userDetails, "Wires as UserDetailsService")
|
||||||
Rel(userDetails, db, "Loads user by email", "JDBC")
|
Rel(userDetails, db, "Loads user by email", "JDBC")
|
||||||
|
Rel(sessionRepo, db, "spring_session, spring_session_attributes", "JDBC")
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
@startuml
|
@startuml
|
||||||
title Authentication Flow (behind Caddy reverse proxy)
|
title Authentication Flow (Spring Session JDBC, behind Caddy reverse proxy)
|
||||||
|
note over Browser, DB
|
||||||
|
Phase 2 of the auth rewrite (ADR-020, ADR-022 / #523, #524).
|
||||||
|
Adds CSRF double-submit cookies, login rate limiting, and
|
||||||
|
session revocation on password change/reset.
|
||||||
|
end note
|
||||||
|
|
||||||
actor User
|
actor User
|
||||||
participant Browser
|
participant Browser
|
||||||
participant "Caddy (TLS termination)" as Caddy
|
participant "Caddy (TLS termination)" as Caddy
|
||||||
participant "Frontend (SvelteKit)" as Frontend
|
participant "Frontend (SvelteKit)" as Frontend
|
||||||
participant "Backend (Spring Boot)" as Backend
|
participant "Backend (Spring Boot)" as Backend
|
||||||
participant PostgreSQL as DB
|
participant "LoginRateLimiter\n(Caffeine+Bucket4j)" as RateLimiter
|
||||||
|
participant "spring_session\n(PostgreSQL)" as DB
|
||||||
|
|
||||||
|
== Login (with rate limiting + CSRF bootstrap) ==
|
||||||
User -> Browser: Enter email + password
|
User -> Browser: Enter email + password
|
||||||
Browser -> Caddy: HTTPS POST /login (form action)
|
Browser -> Caddy: HTTPS POST /?/login (form action)
|
||||||
note right of Caddy
|
note right of Caddy
|
||||||
Caddy terminates TLS and forwards
|
Caddy terminates TLS and forwards
|
||||||
to Frontend over HTTP with:
|
to Frontend over HTTP with:
|
||||||
@@ -17,33 +24,103 @@ note right of Caddy
|
|||||||
X-Forwarded-For: <client IP>
|
X-Forwarded-For: <client IP>
|
||||||
X-Forwarded-Host: archiv.raddatz.cloud
|
X-Forwarded-Host: archiv.raddatz.cloud
|
||||||
end note
|
end note
|
||||||
Caddy -> Frontend: HTTP POST /login\n+ X-Forwarded-Proto: https
|
Caddy -> Frontend: HTTP POST /?/login + X-Forwarded-Proto: https
|
||||||
Frontend -> Frontend: Base64 encode "email:password"
|
Frontend -> Backend: POST /api/auth/login\n{email, password}\n+ X-Forwarded-Proto: https
|
||||||
Frontend -> Backend: GET /api/users/me\nAuthorization: Basic <token>\n+ X-Forwarded-Proto: https
|
|
||||||
note right of Backend
|
note right of Backend
|
||||||
server.forward-headers-strategy: native
|
server.forward-headers-strategy: native
|
||||||
Jetty's ForwardedRequestCustomizer
|
→ request.getScheme() = "https"
|
||||||
reads X-Forwarded-Proto so
|
→ Secure cookie flag set automatically.
|
||||||
request.getScheme() returns "https".
|
|
||||||
end note
|
end note
|
||||||
Backend -> Backend: Spring Security parses Basic Auth
|
Backend -> RateLimiter: checkAndConsume(ip, email)\n[10/15min per ip+email; 20/15min per ip]
|
||||||
Backend -> DB: SELECT user WHERE email=?
|
alt Rate limit exceeded
|
||||||
DB --> Backend: AppUser + groups + permissions
|
RateLimiter --> Backend: throw DomainException(TOO_MANY_LOGIN_ATTEMPTS)
|
||||||
Backend -> Backend: BCrypt.matches(password, hash)
|
Backend -> Backend: AuditService.log(LOGIN_RATE_LIMITED, {ip, email})
|
||||||
Backend --> Frontend: 200 OK — UserDTO
|
Backend --> Frontend: 429 Too Many Requests\n{"code":"TOO_MANY_LOGIN_ATTEMPTS"}
|
||||||
Frontend -> Caddy: Set-Cookie: auth_token=<base64>\n(httpOnly, **Secure**, SameSite=strict, maxAge=86400)
|
Frontend --> Browser: Show rate-limit error
|
||||||
note right of Frontend
|
else Under limit
|
||||||
Secure flag is set because the
|
Backend -> Backend: AuthenticationManager\nauthenticate(email, password)
|
||||||
request scheme observed by the
|
Backend -> DB: SELECT user WHERE email=?
|
||||||
app is https (forwarded by Caddy).
|
DB --> Backend: AppUser + groups + permissions
|
||||||
|
Backend -> Backend: BCrypt.matches(password, hash)\n(timing-safe: dummy hash on miss)
|
||||||
|
Backend -> Backend: getSession(true).setAttribute(\n SPRING_SECURITY_CONTEXT, ctx)
|
||||||
|
Backend -> DB: INSERT spring_session\n+ spring_session_attributes
|
||||||
|
Backend -> RateLimiter: invalidateOnSuccess(ip, email)
|
||||||
|
Backend -> Backend: AuditService.log(LOGIN_SUCCESS,\n {userId, ip, ua})
|
||||||
|
Backend --> Frontend: 200 OK — AppUser\nSet-Cookie: fa_session=<opaque>;\n Path=/; HttpOnly; SameSite=Strict; Secure\nSet-Cookie: XSRF-TOKEN=<token>;\n Path=/; SameSite=Strict; Secure
|
||||||
|
Frontend -> Frontend: Parse Set-Cookie, re-emit fa_session\n(matches backend attrs)
|
||||||
|
Frontend --> Caddy: 303 → /\nSet-Cookie: fa_session=<opaque>
|
||||||
|
Caddy --> Browser: HTTPS 303 + Set-Cookie
|
||||||
|
end
|
||||||
|
|
||||||
|
== Authenticated mutating request (CSRF double-submit) ==
|
||||||
|
note over Browser, Backend
|
||||||
|
handleFetch in hooks.client.ts reads the XSRF-TOKEN cookie
|
||||||
|
and injects X-XSRF-TOKEN header on every POST/PUT/PATCH/DELETE.
|
||||||
end note
|
end note
|
||||||
Caddy -> Browser: HTTPS 200 + Set-Cookie
|
Browser -> Caddy: HTTPS POST /api/...\nCookie: fa_session=<opaque>; XSRF-TOKEN=<token>\nX-XSRF-TOKEN: <token>
|
||||||
Browser -> Caddy: HTTPS GET / (next request)
|
Caddy -> Backend: HTTP POST /api/...\n+ Cookie + X-XSRF-TOKEN
|
||||||
Caddy -> Frontend: HTTP GET / + X-Forwarded-Proto: https
|
alt X-XSRF-TOKEN missing or mismatched
|
||||||
Frontend -> Frontend: hooks.server.ts reads auth_token cookie
|
Backend --> Caddy: 403 Forbidden\n{"code":"CSRF_TOKEN_MISSING"}
|
||||||
Frontend -> Backend: GET /api/users/me\nAuthorization: Basic <token>
|
Caddy --> Browser: HTTPS 403
|
||||||
Backend --> Frontend: 200 OK — user in event.locals
|
else CSRF valid
|
||||||
Frontend --> Caddy: rendered page
|
Backend -> DB: SELECT * FROM spring_session WHERE SESSION_ID = ?
|
||||||
Caddy --> Browser: HTTPS 200
|
DB --> Backend: session row
|
||||||
|
Backend -> Backend: Process request
|
||||||
|
Backend --> Caddy: 2xx response + refreshed XSRF-TOKEN cookie
|
||||||
|
Caddy --> Browser: HTTPS 2xx
|
||||||
|
end
|
||||||
|
|
||||||
|
== Authenticated read request ==
|
||||||
|
Browser -> Caddy: HTTPS GET /\nCookie: fa_session=<opaque>
|
||||||
|
Caddy -> Frontend: HTTP GET / + Cookie + X-Forwarded-Proto: https
|
||||||
|
Frontend -> Frontend: hooks.server.ts reads fa_session
|
||||||
|
Frontend -> Backend: GET /api/users/me\nCookie: fa_session=<opaque>
|
||||||
|
Backend -> DB: SELECT * FROM spring_session\nWHERE SESSION_ID = ?
|
||||||
|
DB --> Backend: row (or null if expired)
|
||||||
|
alt Session valid
|
||||||
|
Backend -> DB: UPDATE spring_session\nSET LAST_ACCESS_TIME = now
|
||||||
|
Backend --> Frontend: 200 OK — AppUser
|
||||||
|
Frontend --> Caddy: rendered page
|
||||||
|
Caddy --> Browser: HTTPS 200
|
||||||
|
else Session expired (idle > 8h) or unknown
|
||||||
|
Backend --> Frontend: 401 Unauthorized
|
||||||
|
Frontend -> Frontend: hooks: delete fa_session cookie
|
||||||
|
Frontend --> Caddy: 302 → /login?reason=expired
|
||||||
|
Caddy --> Browser: HTTPS 302
|
||||||
|
end
|
||||||
|
|
||||||
|
== Password change (revoke other sessions) ==
|
||||||
|
Browser -> Backend: POST /api/users/me/password\n{currentPassword, newPassword}\n+ X-XSRF-TOKEN
|
||||||
|
Backend -> Backend: Verify currentPassword
|
||||||
|
Backend -> DB: UPDATE app_users SET password_hash = ?
|
||||||
|
Backend -> DB: DELETE spring_session WHERE principal = ?\n AND session_id != <current>
|
||||||
|
note right of Backend
|
||||||
|
revokeOtherSessions: caller stays logged in,
|
||||||
|
all other devices are signed out.
|
||||||
|
end note
|
||||||
|
Backend --> Browser: 204 No Content
|
||||||
|
|
||||||
|
== Password reset (revoke all sessions) ==
|
||||||
|
Browser -> Backend: POST /api/auth/reset-password\n{token, newPassword}
|
||||||
|
Backend -> Backend: Verify reset token
|
||||||
|
Backend -> DB: UPDATE app_users SET password_hash = ?
|
||||||
|
Backend -> DB: DELETE spring_session WHERE principal = ?
|
||||||
|
note right of Backend
|
||||||
|
revokeAllSessions: unauthenticated caller has
|
||||||
|
no session to preserve — all sessions wiped.
|
||||||
|
end note
|
||||||
|
Backend --> Browser: 204 No Content
|
||||||
|
|
||||||
|
== Logout ==
|
||||||
|
Browser -> Caddy: HTTPS POST /logout
|
||||||
|
Caddy -> Frontend: HTTP POST /logout\nCookie: fa_session=<opaque>
|
||||||
|
Frontend -> Backend: POST /api/auth/logout\nCookie: fa_session=<opaque>
|
||||||
|
Backend -> Backend: session.invalidate()\nSecurityContextHolder.clearContext()
|
||||||
|
Backend -> DB: DELETE FROM spring_session\nWHERE SESSION_ID = ?
|
||||||
|
Backend -> Backend: AuditService.log(LOGOUT,\n {userId, ip, ua})
|
||||||
|
Backend --> Frontend: 204 No Content
|
||||||
|
Frontend -> Frontend: cookies.delete('fa_session')
|
||||||
|
Frontend --> Caddy: 303 → /login
|
||||||
|
Caddy --> Browser: HTTPS 303 (cookie cleared)
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -14,8 +14,13 @@
|
|||||||
"error_file_too_large": "Die Datei ist zu groß (max. 50 MB).",
|
"error_file_too_large": "Die Datei ist zu groß (max. 50 MB).",
|
||||||
"error_user_not_found": "Der Benutzer wurde nicht gefunden.",
|
"error_user_not_found": "Der Benutzer wurde nicht gefunden.",
|
||||||
"error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.",
|
"error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.",
|
||||||
|
"error_invalid_credentials": "E-Mail-Adresse oder Passwort ist falsch.",
|
||||||
|
"error_session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||||
|
"error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.",
|
||||||
"error_unauthorized": "Sie sind nicht angemeldet.",
|
"error_unauthorized": "Sie sind nicht angemeldet.",
|
||||||
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
|
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
|
||||||
|
"error_csrf_token_missing": "Sitzungsfehler. Bitte laden Sie die Seite neu.",
|
||||||
|
"error_too_many_login_attempts": "Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.",
|
||||||
"error_validation_error": "Die Eingabe ist ungültig.",
|
"error_validation_error": "Die Eingabe ist ungültig.",
|
||||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||||
"nav_documents": "Dokumente",
|
"nav_documents": "Dokumente",
|
||||||
@@ -347,6 +352,11 @@
|
|||||||
"admin_system_import_status_running": "Import läuft…",
|
"admin_system_import_status_running": "Import läuft…",
|
||||||
"admin_system_import_status_done": "Import abgeschlossen",
|
"admin_system_import_status_done": "Import abgeschlossen",
|
||||||
"admin_system_import_status_done_label": "Dokumente verarbeitet",
|
"admin_system_import_status_done_label": "Dokumente verarbeitet",
|
||||||
|
"admin_system_import_skipped_label": "übersprungen",
|
||||||
|
"import_reason_invalid_pdf_signature": "Keine gültige PDF-Signatur",
|
||||||
|
"import_reason_file_read_error": "Fehler beim Lesen der Datei",
|
||||||
|
"import_reason_s3_upload_failed": "Upload-Fehler (S3)",
|
||||||
|
"import_reason_already_exists": "Bereits importiert",
|
||||||
"admin_system_import_status_failed": "Import fehlgeschlagen",
|
"admin_system_import_status_failed": "Import fehlgeschlagen",
|
||||||
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
|
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
|
||||||
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
|
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
|
||||||
|
|||||||
@@ -14,8 +14,13 @@
|
|||||||
"error_file_too_large": "The file is too large (max. 50 MB).",
|
"error_file_too_large": "The file is too large (max. 50 MB).",
|
||||||
"error_user_not_found": "User not found.",
|
"error_user_not_found": "User not found.",
|
||||||
"error_import_already_running": "An import is already running. Please wait for it to finish.",
|
"error_import_already_running": "An import is already running. Please wait for it to finish.",
|
||||||
|
"error_invalid_credentials": "Email address or password is incorrect.",
|
||||||
|
"error_session_expired": "Your session has expired. Please sign in again.",
|
||||||
|
"error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.",
|
||||||
"error_unauthorized": "You are not logged in.",
|
"error_unauthorized": "You are not logged in.",
|
||||||
"error_forbidden": "You do not have permission for this action.",
|
"error_forbidden": "You do not have permission for this action.",
|
||||||
|
"error_csrf_token_missing": "Session error. Please reload the page.",
|
||||||
|
"error_too_many_login_attempts": "Too many login attempts. Please try again later.",
|
||||||
"error_validation_error": "The input is invalid.",
|
"error_validation_error": "The input is invalid.",
|
||||||
"error_internal_error": "An unexpected error occurred.",
|
"error_internal_error": "An unexpected error occurred.",
|
||||||
"nav_documents": "Documents",
|
"nav_documents": "Documents",
|
||||||
@@ -347,6 +352,11 @@
|
|||||||
"admin_system_import_status_running": "Import running…",
|
"admin_system_import_status_running": "Import running…",
|
||||||
"admin_system_import_status_done": "Import complete",
|
"admin_system_import_status_done": "Import complete",
|
||||||
"admin_system_import_status_done_label": "Documents processed",
|
"admin_system_import_status_done_label": "Documents processed",
|
||||||
|
"admin_system_import_skipped_label": "skipped",
|
||||||
|
"import_reason_invalid_pdf_signature": "Invalid PDF signature",
|
||||||
|
"import_reason_file_read_error": "File read error",
|
||||||
|
"import_reason_s3_upload_failed": "Upload error (S3)",
|
||||||
|
"import_reason_already_exists": "Already imported",
|
||||||
"admin_system_import_status_failed": "Import failed",
|
"admin_system_import_status_failed": "Import failed",
|
||||||
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
|
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
|
||||||
"admin_system_import_failed_internal": "Import failed due to an internal error.",
|
"admin_system_import_failed_internal": "Import failed due to an internal error.",
|
||||||
|
|||||||
@@ -14,8 +14,13 @@
|
|||||||
"error_file_too_large": "El archivo es demasiado grande (máx. 50 MB).",
|
"error_file_too_large": "El archivo es demasiado grande (máx. 50 MB).",
|
||||||
"error_user_not_found": "Usuario no encontrado.",
|
"error_user_not_found": "Usuario no encontrado.",
|
||||||
"error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.",
|
"error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.",
|
||||||
|
"error_invalid_credentials": "El correo electrónico o la contraseña son incorrectos.",
|
||||||
|
"error_session_expired": "Su sesión ha expirado. Por favor, inicie sesión de nuevo.",
|
||||||
|
"error_session_expired_explainer": "Por razones de seguridad, las sesiones se terminan automáticamente tras 8 horas de inactividad.",
|
||||||
"error_unauthorized": "No ha iniciado sesión.",
|
"error_unauthorized": "No ha iniciado sesión.",
|
||||||
"error_forbidden": "No tiene permiso para realizar esta acción.",
|
"error_forbidden": "No tiene permiso para realizar esta acción.",
|
||||||
|
"error_csrf_token_missing": "Error de sesión. Recargue la página.",
|
||||||
|
"error_too_many_login_attempts": "Demasiados intentos. Por favor, inténtelo más tarde.",
|
||||||
"error_validation_error": "La entrada no es válida.",
|
"error_validation_error": "La entrada no es válida.",
|
||||||
"error_internal_error": "Se ha producido un error inesperado.",
|
"error_internal_error": "Se ha producido un error inesperado.",
|
||||||
"nav_documents": "Documentos",
|
"nav_documents": "Documentos",
|
||||||
@@ -347,6 +352,11 @@
|
|||||||
"admin_system_import_status_running": "Importación en curso…",
|
"admin_system_import_status_running": "Importación en curso…",
|
||||||
"admin_system_import_status_done": "Importación completada",
|
"admin_system_import_status_done": "Importación completada",
|
||||||
"admin_system_import_status_done_label": "Documentos procesados",
|
"admin_system_import_status_done_label": "Documentos procesados",
|
||||||
|
"admin_system_import_skipped_label": "omitidos",
|
||||||
|
"import_reason_invalid_pdf_signature": "Firma PDF no válida",
|
||||||
|
"import_reason_file_read_error": "Error al leer el archivo",
|
||||||
|
"import_reason_s3_upload_failed": "Error de carga (S3)",
|
||||||
|
"import_reason_already_exists": "Ya importado",
|
||||||
"admin_system_import_status_failed": "Importación fallida",
|
"admin_system_import_status_failed": "Importación fallida",
|
||||||
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
|
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
|
||||||
"admin_system_import_failed_internal": "Error interno durante la importación.",
|
"admin_system_import_failed_internal": "Error interno durante la importación.",
|
||||||
|
|||||||
2878
frontend/package-lock.json
generated
2878
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@
|
|||||||
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
|
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test": "npm run test:unit -- --run",
|
"test": "npm run test:unit -- --run",
|
||||||
"test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage",
|
"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:headed": "playwright test --headed",
|
"test:e2e:headed": "playwright test --headed",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
@@ -24,9 +24,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/sveltekit": "^10.53.1",
|
"@sentry/sveltekit": "^10.53.1",
|
||||||
"@tiptap/core": "3.22.5",
|
"@tiptap/core": "3.23.4",
|
||||||
"@tiptap/extension-mention": "3.22.5",
|
"@tiptap/extension-mention": "3.23.4",
|
||||||
"@tiptap/starter-kit": "3.22.5",
|
"@tiptap/starter-kit": "3.23.4",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"isomorphic-dompurify": "^3.12.0",
|
"isomorphic-dompurify": "^3.12.0",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@inlang/paraglide-js": "^2.5.0",
|
"@inlang/paraglide-js": "^2.5.0",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.60.0",
|
||||||
"@sveltejs/adapter-node": "^5.4.0",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/kit": "^2.48.5",
|
"@sveltejs/kit": "^2.60.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"openapi-typescript": "^7.8.0",
|
"openapi-typescript": "^7.8.0",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.60.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.47.0",
|
"typescript-eslint": "^8.47.0",
|
||||||
"vite": "^7.2.2",
|
"vite": "^7.3.3",
|
||||||
"vite-plugin-devtools-json": "^1.0.0",
|
"vite-plugin-devtools-json": "^1.0.0",
|
||||||
"vitest": "^4.0.10",
|
"vitest": "^4.0.10",
|
||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "^2.0.1"
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
diff --git a/node_modules/@vitest/browser-playwright/dist/index.js b/node_modules/@vitest/browser-playwright/dist/index.js
|
diff --git a/node_modules/@vitest/browser-playwright/dist/index.js b/node_modules/@vitest/browser-playwright/dist/index.js
|
||||||
index 5d0d37b..821d7b4 100644
|
index c01e754..f1bb7be 100644
|
||||||
--- a/node_modules/@vitest/browser-playwright/dist/index.js
|
--- a/node_modules/@vitest/browser-playwright/dist/index.js
|
||||||
+++ b/node_modules/@vitest/browser-playwright/dist/index.js
|
+++ b/node_modules/@vitest/browser-playwright/dist/index.js
|
||||||
@@ -935,7 +935,7 @@ class PlaywrightBrowserProvider {
|
@@ -936,7 +936,7 @@ class PlaywrightBrowserProvider {
|
||||||
createMocker() {
|
createMocker() {
|
||||||
const idPreficates = new Map();
|
const idPredicates = new Map();
|
||||||
const sessionIds = new Map();
|
const sessionIds = new Map();
|
||||||
- function createPredicate(sessionId, url) {
|
- function createPredicate(sessionId, url) {
|
||||||
+ function createPredicate(url) {
|
+ function createPredicate(url) {
|
||||||
const moduleUrl = new URL(url, "http://localhost");
|
const moduleUrl = new URL(url, "http://localhost");
|
||||||
const predicate = (url) => {
|
const predicate = (url) => {
|
||||||
if (url.searchParams.has("_vitest_original")) {
|
if (url.searchParams.has("_vitest_original")) {
|
||||||
@@ -960,11 +960,7 @@ class PlaywrightBrowserProvider {
|
@@ -961,11 +961,7 @@ class PlaywrightBrowserProvider {
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
- const ids = sessionIds.get(sessionId) || [];
|
- const ids = sessionIds.get(sessionId) || [];
|
||||||
- ids.push(moduleUrl.href);
|
- ids.push(moduleUrl.href);
|
||||||
- sessionIds.set(sessionId, ids);
|
- sessionIds.set(sessionId, ids);
|
||||||
- idPreficates.set(predicateKey(sessionId, moduleUrl.href), predicate);
|
- idPredicates.set(predicateKey(sessionId, moduleUrl.href), predicate);
|
||||||
- return predicate;
|
- return predicate;
|
||||||
+ return { url: moduleUrl.href, predicate };
|
+ return { url: moduleUrl.href, predicate };
|
||||||
}
|
}
|
||||||
function predicateKey(sessionId, url) {
|
function predicateKey(sessionId, url) {
|
||||||
return `${sessionId}:${url}`;
|
return `${sessionId}:${url}`;
|
||||||
@@ -972,7 +968,23 @@ class PlaywrightBrowserProvider {
|
@@ -973,7 +969,23 @@ class PlaywrightBrowserProvider {
|
||||||
return {
|
return {
|
||||||
register: async (sessionId, module) => {
|
register: async (sessionId, module) => {
|
||||||
const page = this.getPage(sessionId);
|
const page = this.getPage(sessionId);
|
||||||
@@ -37,19 +37,19 @@ index 5d0d37b..821d7b4 100644
|
|||||||
+ // duplicate-id mocks (e.g. '$lib/foo.svelte' + '$lib/foo.svelte.js')
|
+ // duplicate-id mocks (e.g. '$lib/foo.svelte' + '$lib/foo.svelte.js')
|
||||||
+ // leak an orphan route whose handler crashes after the next
|
+ // leak an orphan route whose handler crashes after the next
|
||||||
+ // session's birpc channel closes.
|
+ // session's birpc channel closes.
|
||||||
+ const existingPredicate = idPreficates.get(key);
|
+ const existingPredicate = idPredicates.get(key);
|
||||||
+ if (existingPredicate) {
|
+ if (existingPredicate) {
|
||||||
+ await page.context().unroute(existingPredicate);
|
+ await page.context().unroute(existingPredicate);
|
||||||
+ }
|
+ }
|
||||||
+ const ids = sessionIds.get(sessionId) ?? new Set();
|
+ const ids = sessionIds.get(sessionId) ?? new Set();
|
||||||
+ ids.add(moduleUrl);
|
+ ids.add(moduleUrl);
|
||||||
+ sessionIds.set(sessionId, ids);
|
+ sessionIds.set(sessionId, ids);
|
||||||
+ idPreficates.set(key, predicate);
|
+ idPredicates.set(key, predicate);
|
||||||
+ await page.context().route(predicate, async (route) => {
|
+ await page.context().route(predicate, async (route) => {
|
||||||
if (module.type === "manual") {
|
if (module.type === "manual") {
|
||||||
const exports$1 = Object.keys(await module.resolve());
|
const exports$1 = Object.keys(await module.resolve());
|
||||||
const body = createManualModuleSource(module.url, exports$1);
|
const body = createManualModuleSource(module.url, exports$1);
|
||||||
@@ -1033,8 +1045,8 @@ class PlaywrightBrowserProvider {
|
@@ -1034,8 +1046,8 @@ class PlaywrightBrowserProvider {
|
||||||
},
|
},
|
||||||
clear: async (sessionId) => {
|
clear: async (sessionId) => {
|
||||||
const page = this.getPage(sessionId);
|
const page = this.getPage(sessionId);
|
||||||
@@ -58,5 +58,5 @@ index 5d0d37b..821d7b4 100644
|
|||||||
+ const ids = sessionIds.get(sessionId) ?? new Set();
|
+ const ids = sessionIds.get(sessionId) ?? new Set();
|
||||||
+ const promises = [...ids].map((id) => {
|
+ const promises = [...ids].map((id) => {
|
||||||
const key = predicateKey(sessionId, id);
|
const key = predicateKey(sessionId, id);
|
||||||
const predicate = idPreficates.get(key);
|
const predicate = idPredicates.get(key);
|
||||||
if (predicate) {
|
if (predicate) {
|
||||||
@@ -6,11 +6,22 @@ vi.mock('@sentry/sveltekit', () => ({
|
|||||||
lastEventId: vi.fn(() => 'sentry-event-id-abc123')
|
lastEventId: vi.fn(() => 'sentry-event-id-abc123')
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@sveltejs/kit', () => ({ redirect: vi.fn() }));
|
class RedirectMarker {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public location: string
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@sveltejs/kit', () => ({
|
||||||
|
redirect: vi.fn((status: number, location: string) => new RedirectMarker(status, location)),
|
||||||
|
isRedirect: (e: unknown) => e instanceof RedirectMarker
|
||||||
|
}));
|
||||||
vi.mock('@sveltejs/kit/hooks', () => ({ sequence: vi.fn((...fns: unknown[]) => fns[0]) }));
|
vi.mock('@sveltejs/kit/hooks', () => ({ sequence: vi.fn((...fns: unknown[]) => fns[0]) }));
|
||||||
vi.mock('$lib/paraglide/server', () => ({ paraglideMiddleware: vi.fn() }));
|
vi.mock('$lib/paraglide/server', () => ({ paraglideMiddleware: vi.fn() }));
|
||||||
vi.mock('$lib/paraglide/runtime', () => ({ cookieName: 'locale', cookieMaxAge: 86400 }));
|
vi.mock('$lib/paraglide/runtime', () => ({ cookieName: 'locale', cookieMaxAge: 86400 }));
|
||||||
vi.mock('$lib/shared/server/locale', () => ({ detectLocale: vi.fn(() => 'de') }));
|
vi.mock('$lib/shared/server/locale', () => ({ detectLocale: vi.fn(() => 'de') }));
|
||||||
|
vi.mock('process', () => ({ env: { API_INTERNAL_URL: 'http://backend:8080' } }));
|
||||||
|
|
||||||
const makeEvent = () => ({
|
const makeEvent = () => ({
|
||||||
url: { pathname: '/documents/123' },
|
url: { pathname: '/documents/123' },
|
||||||
@@ -56,3 +67,86 @@ describe('hooks.server handleError', () => {
|
|||||||
expect(result.message).toBe('An unexpected error occurred');
|
expect(result.message).toBe('An unexpected error occurred');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface UserGroupEvent {
|
||||||
|
url: URL;
|
||||||
|
cookies: { get: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||||
|
locals: { user?: unknown };
|
||||||
|
request: Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUserGroupEvent(pathname: string, sessionId?: string): UserGroupEvent {
|
||||||
|
return {
|
||||||
|
url: new URL(`http://localhost${pathname}`),
|
||||||
|
cookies: {
|
||||||
|
get: vi.fn((name: string) => (name === 'fa_session' ? sessionId : undefined)),
|
||||||
|
delete: vi.fn()
|
||||||
|
},
|
||||||
|
locals: {},
|
||||||
|
request: new Request(`http://localhost${pathname}`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('hooks.server userGroup (session lookup + 401 handling)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to /login?reason=expired when backend rejects the session on a non-public path', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 }));
|
||||||
|
|
||||||
|
const { handle } = await import('./hooks.server');
|
||||||
|
const event = makeUserGroupEvent('/documents/123', 'stale-session');
|
||||||
|
const resolve = vi.fn();
|
||||||
|
|
||||||
|
await expect((handle as (a: unknown) => unknown)({ event, resolve })).rejects.toMatchObject({
|
||||||
|
status: 302,
|
||||||
|
location: '/login?reason=expired'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(event.cookies.delete).toHaveBeenCalledWith('fa_session', { path: '/' });
|
||||||
|
expect(resolve).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not redirect when backend 401 fires on a public path (no /login → /login loop)', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 }));
|
||||||
|
|
||||||
|
const { handle } = await import('./hooks.server');
|
||||||
|
const event = makeUserGroupEvent('/login', 'stale-session');
|
||||||
|
const resolve = vi.fn().mockResolvedValue(new Response());
|
||||||
|
|
||||||
|
await (handle as (a: unknown) => Promise<unknown>)({ event, resolve });
|
||||||
|
|
||||||
|
expect(event.cookies.delete).toHaveBeenCalledWith('fa_session', { path: '/' });
|
||||||
|
expect(resolve).toHaveBeenCalledWith(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through when no fa_session cookie is present', async () => {
|
||||||
|
const { handle } = await import('./hooks.server');
|
||||||
|
const event = makeUserGroupEvent('/documents/123', undefined);
|
||||||
|
const resolve = vi.fn().mockResolvedValue(new Response());
|
||||||
|
|
||||||
|
await (handle as (a: unknown) => Promise<unknown>)({ event, resolve });
|
||||||
|
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
expect(resolve).toHaveBeenCalledWith(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches the user to locals when backend returns 200', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ id: 'u1', email: 'a@b.de' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { handle } = await import('./hooks.server');
|
||||||
|
const event = makeUserGroupEvent('/documents/123', 'valid-session');
|
||||||
|
const resolve = vi.fn().mockResolvedValue(new Response());
|
||||||
|
|
||||||
|
await (handle as (a: unknown) => Promise<unknown>)({ event, resolve });
|
||||||
|
|
||||||
|
expect((event.locals as { user: { email: string } }).user.email).toBe('a@b.de');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as Sentry from '@sentry/sveltekit';
|
import * as Sentry from '@sentry/sveltekit';
|
||||||
import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit';
|
import { isRedirect, redirect, type Handle, type HandleFetch } from '@sveltejs/kit';
|
||||||
import { paraglideMiddleware } from '$lib/paraglide/server';
|
import { paraglideMiddleware } from '$lib/paraglide/server';
|
||||||
import { sequence } from '@sveltejs/kit/hooks';
|
import { sequence } from '@sveltejs/kit/hooks';
|
||||||
import { env } from 'process';
|
import { env } from 'process';
|
||||||
@@ -58,69 +58,95 @@ const handleParaglide: Handle = ({ event, resolve }) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userGroup: Handle = async ({ event, resolve }) => {
|
const userGroup: Handle = async ({ event, resolve }) => {
|
||||||
const auth = event.cookies.get('auth_token');
|
// One-off cleanup of the legacy Basic-credentials cookie from before the Spring Session migration (#523).
|
||||||
|
if (event.cookies.get('auth_token')) {
|
||||||
|
event.cookies.delete('auth_token', { path: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
if (auth) {
|
const sessionId = event.cookies.get('fa_session');
|
||||||
try {
|
if (!sessionId) {
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
return resolve(event);
|
||||||
const response = await fetch(`${apiUrl}/api/users/me`, {
|
}
|
||||||
headers: { Authorization: auth }
|
|
||||||
});
|
try {
|
||||||
if (response.ok) {
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const user = await response.json();
|
const response = await fetch(`${apiUrl}/api/users/me`, {
|
||||||
event.locals.user = user;
|
headers: { Cookie: `fa_session=${sessionId}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
event.locals.user = await response.json();
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
// Backend rejected the session (expired or invalidated). Drop the stale
|
||||||
|
// cookie and surface the reason on the login page. PUBLIC_PATHS check
|
||||||
|
// avoids a redirect loop if the user is already on /login.
|
||||||
|
event.cookies.delete('fa_session', { path: '/' });
|
||||||
|
const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p));
|
||||||
|
if (!isPublic) {
|
||||||
|
throw redirect(302, '/login?reason=expired');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching user in hook:', error);
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Re-throw SvelteKit redirects (e.g. the /login?reason=expired throw above)
|
||||||
|
// using the official guard rather than duck-typing on the error shape.
|
||||||
|
if (isRedirect(error)) throw error;
|
||||||
|
console.error('Error fetching user in hook:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolve(event);
|
return resolve(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||||
|
|
||||||
|
// Auth endpoints that establish/check their own credentials — skip fa_session injection
|
||||||
|
// but still need CSRF tokens on mutating requests.
|
||||||
|
const PUBLIC_API_PATHS = [
|
||||||
|
'/api/auth/login',
|
||||||
|
'/api/auth/logout',
|
||||||
|
'/api/auth/forgot-password',
|
||||||
|
'/api/auth/reset-password',
|
||||||
|
'/api/auth/invite/',
|
||||||
|
'/api/auth/register'
|
||||||
|
];
|
||||||
|
|
||||||
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/');
|
const isApi = request.url.startsWith(apiUrl) || new URL(request.url).pathname.startsWith('/api/');
|
||||||
|
|
||||||
if (isApi) {
|
if (!isApi) return fetch(request);
|
||||||
// If the request already carries an explicit Authorization header (e.g. the
|
|
||||||
// login action sends Basic auth), pass it through unchanged.
|
|
||||||
if (request.headers.has('Authorization')) {
|
|
||||||
return fetch(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password reset endpoints are public — no auth header needed.
|
const isMutating = MUTATING_METHODS.has(request.method);
|
||||||
const PUBLIC_API_PATHS = [
|
const isPublicAuthApi = PUBLIC_API_PATHS.some((p) => request.url.includes(p));
|
||||||
'/api/auth/forgot-password',
|
|
||||||
'/api/auth/reset-password',
|
|
||||||
'/api/auth/invite/',
|
|
||||||
'/api/auth/register'
|
|
||||||
];
|
|
||||||
if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) {
|
|
||||||
return fetch(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = event.cookies.get('auth_token');
|
const sessionId = !isPublicAuthApi ? event.cookies.get('fa_session') : null;
|
||||||
|
if (!isPublicAuthApi && !sessionId) {
|
||||||
if (!token) {
|
return new Response('Unauthorized', { status: 401 });
|
||||||
return new Response('Unauthorized', { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone the request first to preserve the body
|
|
||||||
const clonedRequest = request.clone();
|
|
||||||
|
|
||||||
// Create new request with Authorization header and preserved body
|
|
||||||
const modifiedRequest = new Request(clonedRequest, {
|
|
||||||
headers: {
|
|
||||||
...Object.fromEntries(clonedRequest.headers),
|
|
||||||
Authorization: token
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return fetch(modifiedRequest);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(request);
|
// Read the browser's XSRF-TOKEN cookie; fall back to a fresh UUID for the
|
||||||
|
// double-submit cookie pattern (both cookie and header must match — no server secret).
|
||||||
|
const xsrfToken = isMutating ? (event.cookies.get('XSRF-TOKEN') ?? crypto.randomUUID()) : null;
|
||||||
|
|
||||||
|
const cookieParts: string[] = [];
|
||||||
|
if (sessionId) cookieParts.push(`fa_session=${sessionId}`);
|
||||||
|
if (xsrfToken) cookieParts.push(`XSRF-TOKEN=${xsrfToken}`);
|
||||||
|
|
||||||
|
if (cookieParts.length === 0) {
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone first so the body stream is preserved on the new Request.
|
||||||
|
const cloned = request.clone();
|
||||||
|
const extraHeaders: Record<string, string> = { Cookie: cookieParts.join('; ') };
|
||||||
|
if (xsrfToken) extraHeaders['X-XSRF-TOKEN'] = xsrfToken;
|
||||||
|
|
||||||
|
const modified = new Request(cloned, {
|
||||||
|
headers: {
|
||||||
|
...Object.fromEntries(cloned.headers),
|
||||||
|
...extraHeaders
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return fetch(modified);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);
|
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);
|
||||||
|
|||||||
@@ -180,6 +180,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/users/{id}/force-logout": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["forceLogout"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/users/me/password": {
|
"/api/users/me/password": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -580,6 +596,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/auth/logout": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["logout"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/auth/login": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["login"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/auth/forgot-password": {
|
"/api/auth/forgot-password": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1849,7 +1897,7 @@ export interface components {
|
|||||||
status: string;
|
status: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
shareableUrl?: string;
|
shareableUrl: string;
|
||||||
};
|
};
|
||||||
GroupDTO: {
|
GroupDTO: {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -2011,13 +2059,17 @@ export interface components {
|
|||||||
lastName?: string;
|
lastName?: string;
|
||||||
notifyOnMention?: boolean;
|
notifyOnMention?: boolean;
|
||||||
};
|
};
|
||||||
|
LoginRequest: {
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
ForgotPasswordRequest: {
|
ForgotPasswordRequest: {
|
||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
ImportStatus: {
|
ImportStatus: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
|
||||||
message?: string;
|
statusCode?: string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
processed?: number;
|
processed?: number;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -2255,14 +2307,14 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
totalPages?: number;
|
totalPages?: number;
|
||||||
pageable?: components["schemas"]["PageableObject"];
|
pageable?: components["schemas"]["PageableObject"];
|
||||||
first?: boolean;
|
|
||||||
last?: boolean;
|
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
size?: number;
|
size?: number;
|
||||||
content?: components["schemas"]["NotificationDTO"][];
|
content?: components["schemas"]["NotificationDTO"][];
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
number?: number;
|
number?: number;
|
||||||
sort?: components["schemas"]["SortObject"];
|
sort?: components["schemas"]["SortObject"];
|
||||||
|
first?: boolean;
|
||||||
|
last?: boolean;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
numberOfElements?: number;
|
numberOfElements?: number;
|
||||||
empty?: boolean;
|
empty?: boolean;
|
||||||
@@ -2410,7 +2462,7 @@ export interface components {
|
|||||||
};
|
};
|
||||||
ActivityFeedItemDTO: {
|
ActivityFeedItemDTO: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED";
|
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED";
|
||||||
actor?: components["schemas"]["ActivityActorDTO"];
|
actor?: components["schemas"]["ActivityActorDTO"];
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -2954,6 +3006,30 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
forceLogout: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
changePassword: {
|
changePassword: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3547,6 +3623,7 @@ export interface operations {
|
|||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
|
documentId: string;
|
||||||
blockId: string;
|
blockId: string;
|
||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
@@ -3597,6 +3674,7 @@ export interface operations {
|
|||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
|
blockId: string;
|
||||||
commentId: string;
|
commentId: string;
|
||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
@@ -3791,6 +3869,48 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
logout: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
login: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["LoginRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["AppUser"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
forgotPassword: {
|
forgotPassword: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4985,7 +5105,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"*/*": components["schemas"]["DocumentDensityResult"];
|
"application/json": components["schemas"]["DocumentDensityResult"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -5061,7 +5181,7 @@ export interface operations {
|
|||||||
query?: {
|
query?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
|
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
|
||||||
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED")[];
|
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[];
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
38
frontend/src/lib/shared/cookies.spec.ts
Normal file
38
frontend/src/lib/shared/cookies.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { extractFaSessionId } from './cookies';
|
||||||
|
|
||||||
|
describe('extractFaSessionId', () => {
|
||||||
|
it('extracts the opaque id from a single Set-Cookie header', () => {
|
||||||
|
const headers = ['fa_session=abc123; Path=/; HttpOnly; SameSite=Strict'];
|
||||||
|
expect(extractFaSessionId(headers)).toBe('abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the value when multiple Set-Cookie headers are present (getSetCookie path)', () => {
|
||||||
|
const headers = [
|
||||||
|
'JSESSIONID=legacy; Path=/',
|
||||||
|
'fa_session=xyz789; Path=/; Max-Age=28800; HttpOnly',
|
||||||
|
'XSRF-TOKEN=ignored; Path=/'
|
||||||
|
];
|
||||||
|
expect(extractFaSessionId(headers)).toBe('xyz789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no header carries fa_session', () => {
|
||||||
|
expect(extractFaSessionId(['Other=foo; Path=/'])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an empty header list', () => {
|
||||||
|
expect(extractFaSessionId([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips all attributes after the first semicolon', () => {
|
||||||
|
const headers = ['fa_session=opaque-token-with.dots_and-dashes; Path=/; Secure; HttpOnly'];
|
||||||
|
expect(extractFaSessionId(headers)).toBe('opaque-token-with.dots_and-dashes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only matches a cookie whose name is exactly fa_session', () => {
|
||||||
|
// A different cookie name that happens to contain "fa_session" as a substring
|
||||||
|
// must not match — anchored to start of header.
|
||||||
|
const headers = ['xfa_session=should-not-match; Path=/'];
|
||||||
|
expect(extractFaSessionId(headers)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
frontend/src/lib/shared/cookies.ts
Normal file
20
frontend/src/lib/shared/cookies.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Extracts the fa_session cookie value from a list of Set-Cookie response headers.
|
||||||
|
*
|
||||||
|
* The backend may append attributes like `Path`, `HttpOnly`, `SameSite=Strict`,
|
||||||
|
* `Max-Age`, `Secure`; we only forward the opaque session id — the SvelteKit
|
||||||
|
* cookies API rewrites the attributes itself when re-emitting to the browser.
|
||||||
|
*
|
||||||
|
* Pass the result of `response.headers.getSetCookie()` (modern Node/Undici) or
|
||||||
|
* a single-element array containing `response.headers.get('set-cookie')` for
|
||||||
|
* older runtimes that lack `getSetCookie`.
|
||||||
|
*
|
||||||
|
* Returns `null` if no fa_session cookie is present.
|
||||||
|
*/
|
||||||
|
export function extractFaSessionId(setCookieHeaders: string[]): string | null {
|
||||||
|
for (const header of setCookieHeaders) {
|
||||||
|
const match = header.match(/^fa_session=([^;]+)/);
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -44,9 +44,13 @@ export type ErrorCode =
|
|||||||
| 'CIRCULAR_RELATIONSHIP'
|
| 'CIRCULAR_RELATIONSHIP'
|
||||||
| 'DUPLICATE_RELATIONSHIP'
|
| 'DUPLICATE_RELATIONSHIP'
|
||||||
| 'GESCHICHTE_NOT_FOUND'
|
| 'GESCHICHTE_NOT_FOUND'
|
||||||
|
| 'INVALID_CREDENTIALS'
|
||||||
|
| 'SESSION_EXPIRED'
|
||||||
| 'MISSING_CREDENTIALS'
|
| 'MISSING_CREDENTIALS'
|
||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
|
| 'CSRF_TOKEN_MISSING'
|
||||||
|
| 'TOO_MANY_LOGIN_ATTEMPTS'
|
||||||
| 'VALIDATION_ERROR'
|
| 'VALIDATION_ERROR'
|
||||||
| 'BATCH_TOO_LARGE'
|
| 'BATCH_TOO_LARGE'
|
||||||
| 'BULK_EDIT_TOO_MANY_IDS'
|
| 'BULK_EDIT_TOO_MANY_IDS'
|
||||||
@@ -154,12 +158,20 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_duplicate_relationship();
|
return m.error_duplicate_relationship();
|
||||||
case 'GESCHICHTE_NOT_FOUND':
|
case 'GESCHICHTE_NOT_FOUND':
|
||||||
return m.error_geschichte_not_found();
|
return m.error_geschichte_not_found();
|
||||||
|
case 'INVALID_CREDENTIALS':
|
||||||
|
return m.error_invalid_credentials();
|
||||||
|
case 'SESSION_EXPIRED':
|
||||||
|
return m.error_session_expired();
|
||||||
case 'MISSING_CREDENTIALS':
|
case 'MISSING_CREDENTIALS':
|
||||||
return m.login_error_missing_credentials();
|
return m.login_error_missing_credentials();
|
||||||
case 'UNAUTHORIZED':
|
case 'UNAUTHORIZED':
|
||||||
return m.error_unauthorized();
|
return m.error_unauthorized();
|
||||||
case 'FORBIDDEN':
|
case 'FORBIDDEN':
|
||||||
return m.error_forbidden();
|
return m.error_forbidden();
|
||||||
|
case 'CSRF_TOKEN_MISSING':
|
||||||
|
return m.error_csrf_token_missing();
|
||||||
|
case 'TOO_MANY_LOGIN_ATTEMPTS':
|
||||||
|
return m.error_too_many_login_attempts();
|
||||||
case 'VALIDATION_ERROR':
|
case 'VALIDATION_ERROR':
|
||||||
return m.error_validation_error();
|
return m.error_validation_error();
|
||||||
case 'BATCH_TOO_LARGE':
|
case 'BATCH_TOO_LARGE':
|
||||||
|
|||||||
@@ -19,14 +19,22 @@ describe('admin/groups layout load', () => {
|
|||||||
{ id: 'g1', name: 'Admins', permissions: ['ADMIN'] },
|
{ id: 'g1', name: 'Admins', permissions: ['ADMIN'] },
|
||||||
{ id: 'g2', name: 'Editors', permissions: ['WRITE_ALL'] }
|
{ id: 'g2', name: 'Editors', permissions: ['WRITE_ALL'] }
|
||||||
]);
|
]);
|
||||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
const result = await load({
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
request: new Request('http://localhost/admin/groups'),
|
||||||
|
url: new URL('http://localhost/admin/groups')
|
||||||
|
});
|
||||||
expect(result.groups).toHaveLength(2);
|
expect(result.groups).toHaveLength(2);
|
||||||
expect(result.groups[0].name).toBe('Admins');
|
expect(result.groups[0].name).toBe('Admins');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns an empty array when the API returns nothing', async () => {
|
it('returns an empty array when the API returns nothing', async () => {
|
||||||
mockApi([]);
|
mockApi([]);
|
||||||
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
|
const result = await load({
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
request: new Request('http://localhost/admin/groups'),
|
||||||
|
url: new URL('http://localhost/admin/groups')
|
||||||
|
});
|
||||||
expect(result.groups).toEqual([]);
|
expect(result.groups).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,7 +43,11 @@ describe('admin/groups layout load', () => {
|
|||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
>);
|
>);
|
||||||
await load({ fetch: vi.fn() as unknown as typeof fetch });
|
await load({
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
request: new Request('http://localhost/admin/groups'),
|
||||||
|
url: new URL('http://localhost/admin/groups')
|
||||||
|
});
|
||||||
expect(mockGet).toHaveBeenCalledWith('/api/groups');
|
expect(mockGet).toHaveBeenCalledWith('/api/groups');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,50 +1,43 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { env } from '$env/dynamic/private';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { parseBackendError } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
export interface InviteListItem {
|
export type InviteListItem = components['schemas']['InviteListItemDTO'];
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
displayCode: string;
|
|
||||||
label?: string;
|
|
||||||
useCount: number;
|
|
||||||
maxUses?: number;
|
|
||||||
expiresAt?: string;
|
|
||||||
revoked: boolean;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
shareableUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UserGroup = components['schemas']['UserGroup'];
|
export type UserGroup = components['schemas']['UserGroup'];
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
const VALID_STATUSES = ['ACTIVE', 'REVOKED', 'EXPIRED'] as const;
|
||||||
const status = url.searchParams.get('status') ?? 'active';
|
type InviteStatus = (typeof VALID_STATUSES)[number];
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
|
||||||
|
|
||||||
const [invitesRes, groupsRes] = await Promise.all([
|
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||||
fetch(`${apiUrl}/api/invites?status=${encodeURIComponent(status)}`),
|
const rawStatus = url.searchParams.get('status');
|
||||||
fetch(`${apiUrl}/api/groups`)
|
const status: InviteStatus = VALID_STATUSES.includes(rawStatus as InviteStatus)
|
||||||
|
? (rawStatus as InviteStatus)
|
||||||
|
: 'ACTIVE';
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
|
const [invitesResult, groupsResult] = await Promise.all([
|
||||||
|
api.GET('/api/invites', { params: { query: { status } } }),
|
||||||
|
api.GET('/api/groups')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let invites: InviteListItem[] = [];
|
let invites: InviteListItem[] = [];
|
||||||
let loadError: string | null = null;
|
let loadError: string | null = null;
|
||||||
if (!invitesRes.ok) {
|
if (!invitesResult.response.ok) {
|
||||||
const backendError = await parseBackendError(invitesRes);
|
const code = (invitesResult.error as unknown as { code?: string })?.code;
|
||||||
loadError = backendError?.code ?? 'INTERNAL_ERROR';
|
loadError = code ?? 'INTERNAL_ERROR';
|
||||||
} else {
|
} else {
|
||||||
invites = await invitesRes.json();
|
invites = (invitesResult.data ?? []) as InviteListItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let groups: UserGroup[] = [];
|
let groups: UserGroup[] = [];
|
||||||
let groupsLoadError: string | null = null;
|
let groupsLoadError: string | null = null;
|
||||||
if (!groupsRes.ok) {
|
if (!groupsResult.response.ok) {
|
||||||
const backendError = await parseBackendError(groupsRes);
|
const code = (groupsResult.error as unknown as { code?: string })?.code;
|
||||||
groupsLoadError = backendError?.code ?? 'INTERNAL_ERROR';
|
groupsLoadError = code ?? 'INTERNAL_ERROR';
|
||||||
} else {
|
} else {
|
||||||
const raw: UserGroup[] = await groupsRes.json();
|
const raw = groupsResult.data ?? [];
|
||||||
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,42 +56,30 @@ export const actions = {
|
|||||||
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
const expiresAt = (formData.get('expiresAt') as string) || undefined;
|
||||||
const groupIds = formData.getAll('groupIds') as string[];
|
const groupIds = formData.getAll('groupIds') as string[];
|
||||||
|
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const api = createApiClient(fetch);
|
||||||
const res = await fetch(`${apiUrl}/api/invites`, {
|
const result = await api.POST('/api/invites', {
|
||||||
method: 'POST',
|
body: { label, maxUses, prefillFirstName, prefillLastName, prefillEmail, expiresAt, groupIds }
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
label,
|
|
||||||
maxUses,
|
|
||||||
prefillFirstName,
|
|
||||||
prefillLastName,
|
|
||||||
prefillEmail,
|
|
||||||
expiresAt,
|
|
||||||
groupIds
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!result.response.ok) {
|
||||||
const backendError = await parseBackendError(res);
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
return fail(res.status, { createError: backendError?.code ?? 'INTERNAL_ERROR' });
|
return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const created: InviteListItem = await res.json();
|
return { created: result.data! as InviteListItem };
|
||||||
return { created };
|
|
||||||
},
|
},
|
||||||
|
|
||||||
revoke: async ({ request, fetch }) => {
|
revoke: async ({ request, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const id = formData.get('id') as string;
|
const id = formData.get('id') as string | null;
|
||||||
|
if (!id) return fail(400, { revokeError: getErrorMessage('VALIDATION_ERROR') });
|
||||||
|
|
||||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
const api = createApiClient(fetch);
|
||||||
const res = await fetch(`${apiUrl}/api/invites/${encodeURIComponent(id)}`, {
|
const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } });
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!result.response.ok) {
|
||||||
const backendError = await parseBackendError(res);
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
return fail(res.status, { revokeError: backendError?.code ?? 'INTERNAL_ERROR' });
|
return fail(result.response.status, { revokeError: code ?? 'INTERNAL_ERROR' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { revoked: id };
|
return { revoked: id };
|
||||||
|
|||||||
284
frontend/src/routes/admin/invites/page.server.spec.ts
Normal file
284
frontend/src/routes/admin/invites/page.server.spec.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({
|
||||||
|
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { load, actions } from './+page.server';
|
||||||
|
import type { UserGroup } from './+page.server';
|
||||||
|
|
||||||
|
// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids
|
||||||
|
// the void and the Record<string, any> from the generic constraint.
|
||||||
|
type LoadData = {
|
||||||
|
invites: unknown[];
|
||||||
|
status: string;
|
||||||
|
loadError: string | null;
|
||||||
|
groups: UserGroup[];
|
||||||
|
groupsLoadError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type AnyFetch = (...args: any[]) => any;
|
||||||
|
|
||||||
|
function mockResponse(ok: boolean, body: unknown, status = 200) {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' })
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin/invites load()', () => {
|
||||||
|
const mockFetch = vi.fn<AnyFetch>();
|
||||||
|
|
||||||
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
function event(status = 'active') {
|
||||||
|
const url = new URL(`http://localhost/admin/invites?status=${status}`);
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
request: new Request(url),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns groups array alongside invites when both succeed', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
||||||
|
mockResponse(true, [
|
||||||
|
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
||||||
|
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = (await load(event())) as LoadData;
|
||||||
|
|
||||||
|
expect(result.groups).toHaveLength(2);
|
||||||
|
expect(result.groupsLoadError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns groups sorted alphabetically by name', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
||||||
|
mockResponse(true, [
|
||||||
|
{ id: 'g-1', name: 'Zebra', permissions: [] },
|
||||||
|
{ id: 'g-2', name: 'Alfa', permissions: [] },
|
||||||
|
{ id: 'g-3', name: 'Mitte', permissions: [] }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = (await load(event())) as LoadData;
|
||||||
|
|
||||||
|
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(mockResponse(true, []))
|
||||||
|
.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
|
||||||
|
|
||||||
|
const result = (await load(event())) as LoadData;
|
||||||
|
|
||||||
|
expect(result.groups).toEqual([]);
|
||||||
|
expect(result.groupsLoadError).toBe('FORBIDDEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(mockResponse(true, []))
|
||||||
|
.mockResolvedValueOnce(mockResponse(false, null, 500));
|
||||||
|
|
||||||
|
const result = (await load(event())) as LoadData;
|
||||||
|
|
||||||
|
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches invites and groups in parallel (both URLs called)', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce(mockResponse(true, []))
|
||||||
|
.mockResolvedValueOnce(mockResponse(true, []));
|
||||||
|
|
||||||
|
await load(event());
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
// createApiClient calls fetch(Request, {}), not fetch(string, init)
|
||||||
|
const urls = mockFetch.mock.calls.map((call) => (call[0] as Request).url);
|
||||||
|
expect(urls).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining('/api/invites'),
|
||||||
|
expect.stringContaining('/api/groups')
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin/invites create action', () => {
|
||||||
|
const mockFetch = vi.fn<AnyFetch>();
|
||||||
|
|
||||||
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
const successBody = {
|
||||||
|
id: 'inv-1',
|
||||||
|
code: 'ABCDE12345',
|
||||||
|
displayCode: 'ABCDE-12345',
|
||||||
|
status: 'active',
|
||||||
|
revoked: false,
|
||||||
|
useCount: 0,
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
shareableUrl: 'http://localhost/register?code=ABCDE12345'
|
||||||
|
};
|
||||||
|
|
||||||
|
it('includes groupIds array in POST body when checkboxes are checked', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('groupIds', 'g-1');
|
||||||
|
fd.append('groupIds', 'g-2');
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||||
|
|
||||||
|
await actions.create({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// createApiClient calls fetch(Request, {}), not fetch(string, init)
|
||||||
|
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
|
||||||
|
expect(req).toBeInstanceOf(Request);
|
||||||
|
expect(req.url).toContain('/api/invites');
|
||||||
|
const sent = await req.json();
|
||||||
|
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends groupIds: [] when no checkboxes are checked', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||||
|
|
||||||
|
await actions.create({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
|
||||||
|
expect(req).toBeInstanceOf(Request);
|
||||||
|
const sent = await req.json();
|
||||||
|
expect(sent.groupIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns created invite on success', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||||
|
|
||||||
|
const result = await actions.create({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ created: expect.objectContaining({ id: 'inv-1' }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail with backend error code when create returns non-OK', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
|
||||||
|
|
||||||
|
const result = await actions.create({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 403, data: { createError: 'FORBIDDEN' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to INTERNAL_ERROR when create error body has no code', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(false, null, 500));
|
||||||
|
|
||||||
|
const result = await actions.create({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 500, data: { createError: 'INTERNAL_ERROR' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes expiresAt in POST body when provided', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('expiresAt', '2026-12-31');
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
||||||
|
|
||||||
|
await actions.create({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
|
||||||
|
const sent = await req.json();
|
||||||
|
expect(sent.expiresAt).toBe('2026-12-31');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin/invites revoke action', () => {
|
||||||
|
const mockFetch = vi.fn<AnyFetch>();
|
||||||
|
|
||||||
|
beforeEach(() => mockFetch.mockReset());
|
||||||
|
|
||||||
|
it('calls DELETE /api/invites/{id} via createApiClient', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('id', 'inv-abc');
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, null, 200));
|
||||||
|
|
||||||
|
await actions.revoke({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const [req] = mockFetch.mock.calls[0] as [Request, unknown];
|
||||||
|
expect(req).toBeInstanceOf(Request);
|
||||||
|
expect(req.url).toContain('/api/invites/inv-abc');
|
||||||
|
expect(req.method).toBe('DELETE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns revoked id on success', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('id', 'inv-abc');
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(true, null, 200));
|
||||||
|
|
||||||
|
const result = await actions.revoke({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result).toEqual({ revoked: 'inv-abc' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail with backend error code when revoke returns non-OK', async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('id', 'inv-abc');
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse(false, { code: 'NOT_FOUND' }, 404));
|
||||||
|
|
||||||
|
const result = await actions.revoke({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 404, data: { revokeError: 'NOT_FOUND' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fail(400) when revoke id is missing', async () => {
|
||||||
|
const result = await actions.revoke({
|
||||||
|
request: new Request('http://localhost', { method: 'POST', body: new FormData() }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
expect(result).toMatchObject({ status: 400 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('$env/dynamic/private', () => ({
|
|
||||||
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { load, actions } from './+page.server';
|
|
||||||
import type { UserGroup } from './+page.server';
|
|
||||||
|
|
||||||
// PageServerLoad annotates the return as `void | (...)`. This explicit shape avoids
|
|
||||||
// the void and the Record<string, any> from the generic constraint.
|
|
||||||
type LoadData = {
|
|
||||||
invites: unknown[];
|
|
||||||
status: string;
|
|
||||||
loadError: string | null;
|
|
||||||
groups: UserGroup[];
|
|
||||||
groupsLoadError: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
type AnyFetch = (...args: any[]) => any;
|
|
||||||
|
|
||||||
function mockResponse(ok: boolean, body: unknown, status = 200) {
|
|
||||||
return {
|
|
||||||
ok,
|
|
||||||
status,
|
|
||||||
json: async () => body,
|
|
||||||
text: async () => JSON.stringify(body),
|
|
||||||
headers: new Headers({ 'content-type': 'application/json' })
|
|
||||||
} as unknown as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('admin/invites load()', () => {
|
|
||||||
const mockFetch = vi.fn<AnyFetch>();
|
|
||||||
|
|
||||||
beforeEach(() => mockFetch.mockReset());
|
|
||||||
|
|
||||||
function event(status = 'active') {
|
|
||||||
return {
|
|
||||||
url: new URL(`http://localhost/admin/invites?status=${status}`),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('returns groups array alongside invites when both succeed', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
|
||||||
mockResponse(true, [
|
|
||||||
{ id: 'g-1', name: 'Familie', permissions: ['READ_ALL'] },
|
|
||||||
{ id: 'g-2', name: 'Administratoren', permissions: ['ADMIN'] }
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = (await load(event())) as LoadData;
|
|
||||||
|
|
||||||
expect(result.groups).toHaveLength(2);
|
|
||||||
expect(result.groupsLoadError).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns groups sorted alphabetically by name', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(true, [])).mockResolvedValueOnce(
|
|
||||||
mockResponse(true, [
|
|
||||||
{ id: 'g-1', name: 'Zebra', permissions: [] },
|
|
||||||
{ id: 'g-2', name: 'Alfa', permissions: [] },
|
|
||||||
{ id: 'g-3', name: 'Mitte', permissions: [] }
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = (await load(event())) as LoadData;
|
|
||||||
|
|
||||||
expect(result.groups.map((g) => g.name)).toEqual(['Alfa', 'Mitte', 'Zebra']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns groups: [] and non-null groupsLoadError when groups fetch is non-OK', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(mockResponse(true, []))
|
|
||||||
.mockResolvedValueOnce(mockResponse(false, { code: 'FORBIDDEN' }, 403));
|
|
||||||
|
|
||||||
const result = (await load(event())) as LoadData;
|
|
||||||
|
|
||||||
expect(result.groups).toEqual([]);
|
|
||||||
expect(result.groupsLoadError).toBe('FORBIDDEN');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to INTERNAL_ERROR when groups error body has no code', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(mockResponse(true, []))
|
|
||||||
.mockResolvedValueOnce(mockResponse(false, null, 500));
|
|
||||||
|
|
||||||
const result = (await load(event())) as LoadData;
|
|
||||||
|
|
||||||
expect(result.groupsLoadError).toBe('INTERNAL_ERROR');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches invites and groups in parallel (both URLs called)', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(mockResponse(true, []))
|
|
||||||
.mockResolvedValueOnce(mockResponse(true, []));
|
|
||||||
|
|
||||||
await load(event());
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/invites'));
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/api/groups'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('admin/invites create action', () => {
|
|
||||||
const mockFetch = vi.fn<AnyFetch>();
|
|
||||||
|
|
||||||
beforeEach(() => mockFetch.mockReset());
|
|
||||||
|
|
||||||
const successBody = {
|
|
||||||
id: 'inv-1',
|
|
||||||
code: 'ABCDE12345',
|
|
||||||
displayCode: 'ABCDE-12345',
|
|
||||||
status: 'active',
|
|
||||||
revoked: false,
|
|
||||||
useCount: 0,
|
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
|
||||||
shareableUrl: 'http://localhost/register?code=ABCDE12345'
|
|
||||||
};
|
|
||||||
|
|
||||||
it('includes groupIds array in POST body when checkboxes are checked', async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('groupIds', 'g-1');
|
|
||||||
fd.append('groupIds', 'g-2');
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
|
||||||
|
|
||||||
await actions.create({
|
|
||||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
||||||
const sent = JSON.parse(init.body as string);
|
|
||||||
expect(sent.groupIds).toEqual(['g-1', 'g-2']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends groupIds: [] when no checkboxes are checked', async () => {
|
|
||||||
const fd = new FormData();
|
|
||||||
mockFetch.mockResolvedValueOnce(mockResponse(true, successBody, 201));
|
|
||||||
|
|
||||||
await actions.create({
|
|
||||||
request: new Request('http://localhost', { method: 'POST', body: fd }),
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
||||||
const sent = JSON.parse(init.body as string);
|
|
||||||
expect(sent.groupIds).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -26,26 +26,46 @@ beforeEach(() => vi.clearAllMocks());
|
|||||||
describe('admin layout load — permission check', () => {
|
describe('admin layout load — permission check', () => {
|
||||||
it('throws 403 when user has no admin permission', async () => {
|
it('throws 403 when user has no admin permission', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: noPermUser } })
|
load({
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
request: new Request('http://localhost/admin'),
|
||||||
|
url: new URL('http://localhost/admin'),
|
||||||
|
locals: { user: noPermUser }
|
||||||
|
})
|
||||||
).rejects.toMatchObject({ status: 403 });
|
).rejects.toMatchObject({ status: 403 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 403 when user is undefined', async () => {
|
it('throws 403 when user is undefined', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: undefined } })
|
load({
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
request: new Request('http://localhost/admin'),
|
||||||
|
url: new URL('http://localhost/admin'),
|
||||||
|
locals: { user: undefined }
|
||||||
|
})
|
||||||
).rejects.toMatchObject({ status: 403 });
|
).rejects.toMatchObject({ status: 403 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 403 when user has no groups', async () => {
|
it('throws 403 when user has no groups', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: { groups: [] } } })
|
load({
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
request: new Request('http://localhost/admin'),
|
||||||
|
url: new URL('http://localhost/admin'),
|
||||||
|
locals: { user: { groups: [] } }
|
||||||
|
})
|
||||||
).rejects.toMatchObject({ status: 403 });
|
).rejects.toMatchObject({ status: 403 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows access for a user with ADMIN_TAG only', async () => {
|
it('allows access for a user with ADMIN_TAG only', async () => {
|
||||||
mockApi([], [], []);
|
mockApi([], [], []);
|
||||||
await expect(
|
await expect(
|
||||||
load({ fetch: vi.fn() as unknown as typeof fetch, locals: { user: tagAdminUser } })
|
load({
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch,
|
||||||
|
request: new Request('http://localhost/admin'),
|
||||||
|
url: new URL('http://localhost/admin'),
|
||||||
|
locals: { user: tagAdminUser }
|
||||||
|
})
|
||||||
).resolves.toBeDefined();
|
).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,6 +83,8 @@ describe('admin layout load — permission check', () => {
|
|||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
fetch: mockFetch as unknown as typeof fetch,
|
fetch: mockFetch as unknown as typeof fetch,
|
||||||
|
request: new Request('http://localhost/admin'),
|
||||||
|
url: new URL('http://localhost/admin'),
|
||||||
locals: { user: adminUser }
|
locals: { user: adminUser }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ describe('admin/ocr/[personId] — load', () => {
|
|||||||
data: { runs: [], personNames: { [personId]: 'Anna Müller' } }
|
data: { runs: [], personNames: { [personId]: 'Anna Müller' } }
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = (await load({ params: { personId }, fetch } as never))!;
|
const result = (await load({
|
||||||
|
params: { personId },
|
||||||
|
fetch,
|
||||||
|
request: new Request('http://localhost/admin/ocr/123'),
|
||||||
|
url: new URL('http://localhost/admin/ocr/123')
|
||||||
|
} as never))!;
|
||||||
|
|
||||||
expect(result.history.personNames?.[personId]).toBe('Anna Müller');
|
expect(result.history.personNames?.[personId]).toBe('Anna Müller');
|
||||||
});
|
});
|
||||||
@@ -27,7 +32,12 @@ describe('admin/ocr/[personId] — load', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({ params: { personId: 'unknown-id' }, fetch } as never)
|
load({
|
||||||
|
params: { personId: 'unknown-id' },
|
||||||
|
fetch,
|
||||||
|
request: new Request('http://localhost/admin/ocr/unknown-id'),
|
||||||
|
url: new URL('http://localhost/admin/ocr/unknown-id')
|
||||||
|
} as never)
|
||||||
).rejects.toMatchObject({ status: 404 });
|
).rejects.toMatchObject({ status: 404 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ describe('admin/ocr/global — load', () => {
|
|||||||
data: { runs: [{ id: 'run1' }], personNames: {} }
|
data: { runs: [{ id: 'run1' }], personNames: {} }
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = (await load({ fetch } as never))!;
|
const result = (await load({
|
||||||
|
fetch,
|
||||||
|
request: new Request('http://localhost/admin/ocr/global'),
|
||||||
|
url: new URL('http://localhost/admin/ocr/global')
|
||||||
|
} as never))!;
|
||||||
|
|
||||||
expect(result.history.runs).toHaveLength(1);
|
expect(result.history.runs).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -22,6 +26,12 @@ describe('admin/ocr/global — load', () => {
|
|||||||
it('throws error when API call fails', async () => {
|
it('throws error when API call fails', async () => {
|
||||||
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 500 }, error: {} });
|
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 500 }, error: {} });
|
||||||
|
|
||||||
await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 500 });
|
await expect(
|
||||||
|
load({
|
||||||
|
fetch,
|
||||||
|
request: new Request('http://localhost/admin/ocr/global'),
|
||||||
|
url: new URL('http://localhost/admin/ocr/global')
|
||||||
|
} as never)
|
||||||
|
).rejects.toMatchObject({ status: 500 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ describe('admin/ocr — load', () => {
|
|||||||
data: { availableBlocks: 10, ocrServiceAvailable: true, senderModels: [] }
|
data: { availableBlocks: 10, ocrServiceAvailable: true, senderModels: [] }
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = (await load({ fetch } as never))!;
|
const result = (await load({
|
||||||
|
fetch,
|
||||||
|
request: new Request('http://localhost/admin/ocr'),
|
||||||
|
url: new URL('http://localhost/admin/ocr')
|
||||||
|
} as never))!;
|
||||||
|
|
||||||
expect(result.trainingInfo.availableBlocks).toBe(10);
|
expect(result.trainingInfo.availableBlocks).toBe(10);
|
||||||
expect(result.trainingInfo.ocrServiceAvailable).toBe(true);
|
expect(result.trainingInfo.ocrServiceAvailable).toBe(true);
|
||||||
@@ -23,6 +27,12 @@ describe('admin/ocr — load', () => {
|
|||||||
it('throws 503 when OCR API call fails', async () => {
|
it('throws 503 when OCR API call fails', async () => {
|
||||||
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 503 }, error: {} });
|
mockApi.GET.mockResolvedValue({ response: { ok: false, status: 503 }, error: {} });
|
||||||
|
|
||||||
await expect(load({ fetch } as never)).rejects.toMatchObject({ status: 503 });
|
await expect(
|
||||||
|
load({
|
||||||
|
fetch,
|
||||||
|
request: new Request('http://localhost/admin/ocr'),
|
||||||
|
url: new URL('http://localhost/admin/ocr')
|
||||||
|
} as never)
|
||||||
|
).rejects.toMatchObject({ status: 503 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ const failureMessage = $derived(
|
|||||||
? m.admin_system_import_failed_no_spreadsheet()
|
? m.admin_system_import_failed_no_spreadsheet()
|
||||||
: m.admin_system_import_failed_internal()
|
: m.admin_system_import_failed_internal()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function reasonLabel(code: string): string {
|
||||||
|
if (code === 'INVALID_PDF_SIGNATURE') return m.import_reason_invalid_pdf_signature();
|
||||||
|
if (code === 'FILE_READ_ERROR') return m.import_reason_file_read_error();
|
||||||
|
if (code === 'S3_UPLOAD_FAILED') return m.import_reason_s3_upload_failed();
|
||||||
|
if (code === 'ALREADY_EXISTS') return m.import_reason_already_exists();
|
||||||
|
return code;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
@@ -48,6 +56,41 @@ const failureMessage = $derived(
|
|||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
|
<p class="mt-1 text-xs text-green-800">{m.admin_system_import_status_done()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div aria-live="polite">
|
||||||
|
{#if importStatus.skipped > 0}
|
||||||
|
<details class="mb-4 rounded-sm border border-warning/40 bg-warning/10 p-4 text-amber-900">
|
||||||
|
<summary class="flex min-h-[44px] cursor-pointer list-none items-center gap-2 py-2">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="details-chevron h-4 w-4 shrink-0 motion-safe:transition-transform"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M6 4l4 4-4 4" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span data-testid="skipped-count" class="block text-base font-bold"
|
||||||
|
>{importStatus.skipped}</span
|
||||||
|
>
|
||||||
|
<span class="block font-sans text-xs font-bold tracking-widest uppercase">
|
||||||
|
{m.admin_system_import_skipped_label()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<ul class="mt-3 max-h-64 space-y-1 overflow-y-auto">
|
||||||
|
{#each importStatus.skippedFiles as skipped (skipped.filename)}
|
||||||
|
<li class="font-mono text-sm text-ink-2">
|
||||||
|
{skipped.filename} — {reasonLabel(skipped.reason)}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
data-import-trigger
|
data-import-trigger
|
||||||
onclick={ontrigger}
|
onclick={ontrigger}
|
||||||
@@ -79,3 +122,9 @@ const failureMessage = $derived(
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
details[open] .details-chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const makeStatus = (overrides: Partial<ImportStatus> = {}): ImportStatus => ({
|
|||||||
state: 'IDLE',
|
state: 'IDLE',
|
||||||
statusCode: 'IMPORT_IDLE',
|
statusCode: 'IMPORT_IDLE',
|
||||||
processed: 0,
|
processed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
skippedFiles: [],
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
@@ -128,4 +130,106 @@ describe('ImportStatusCard', () => {
|
|||||||
await getByRole('button').click();
|
await getByRole('button').click();
|
||||||
expect(ontrigger).toHaveBeenCalledOnce();
|
expect(ontrigger).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows skipped count when DONE and skipped > 0', async () => {
|
||||||
|
const { getByTestId } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({
|
||||||
|
state: 'DONE',
|
||||||
|
statusCode: 'IMPORT_DONE',
|
||||||
|
processed: 10,
|
||||||
|
skipped: 3,
|
||||||
|
skippedFiles: [
|
||||||
|
{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||||
|
{ filename: 'other.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||||
|
{ filename: 'tiny.pdf', reason: 'INVALID_PDF_SIGNATURE' }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByTestId('skipped-count')).toHaveTextContent('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows skipped filenames in collapsible list when DONE and skipped > 0', async () => {
|
||||||
|
const { getByText } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({
|
||||||
|
state: 'DONE',
|
||||||
|
statusCode: 'IMPORT_DONE',
|
||||||
|
processed: 5,
|
||||||
|
skipped: 1,
|
||||||
|
skippedFiles: [{ filename: 'fake.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
|
||||||
|
}),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByText('fake.pdf')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show skipped section when DONE and skipped is 0', async () => {
|
||||||
|
const { getByTestId } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({ state: 'DONE', statusCode: 'IMPORT_DONE', processed: 5 }),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show skipped section when RUNNING even with skipped > 0', async () => {
|
||||||
|
const { getByTestId } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({
|
||||||
|
state: 'RUNNING',
|
||||||
|
statusCode: 'IMPORT_RUNNING',
|
||||||
|
processed: 5,
|
||||||
|
skipped: 2,
|
||||||
|
skippedFiles: [
|
||||||
|
{ filename: 'a.pdf', reason: 'INVALID_PDF_SIGNATURE' },
|
||||||
|
{ filename: 'b.pdf', reason: 'INVALID_PDF_SIGNATURE' }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show skipped section when FAILED even with skipped > 0', async () => {
|
||||||
|
const { getByTestId } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({
|
||||||
|
state: 'FAILED',
|
||||||
|
statusCode: 'IMPORT_FAILED_INTERNAL',
|
||||||
|
skipped: 1,
|
||||||
|
skippedFiles: [{ filename: 'bad.pdf', reason: 'INVALID_PDF_SIGNATURE' }]
|
||||||
|
}),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByTestId('skipped-count')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows raw reason code for unknown skip reasons', async () => {
|
||||||
|
const { getByText } = render(ImportStatusCard, {
|
||||||
|
props: {
|
||||||
|
importStatus: makeStatus({
|
||||||
|
state: 'DONE',
|
||||||
|
statusCode: 'IMPORT_DONE',
|
||||||
|
processed: 1,
|
||||||
|
skipped: 1,
|
||||||
|
skippedFiles: [{ filename: 'odd.pdf', reason: 'SOME_FUTURE_CODE' }]
|
||||||
|
}),
|
||||||
|
ontrigger: () => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(getByText('SOME_FUTURE_CODE', { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
export type SkippedFile = {
|
||||||
|
filename: string;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ImportStatus = {
|
export type ImportStatus = {
|
||||||
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
state: 'IDLE' | 'RUNNING' | 'DONE' | 'FAILED';
|
||||||
statusCode: string;
|
statusCode: string;
|
||||||
processed: number;
|
processed: number;
|
||||||
|
skipped: number;
|
||||||
|
skippedFiles: SkippedFile[];
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ describe('tags/[id] — load function', () => {
|
|||||||
const result = await load({
|
const result = await load({
|
||||||
params: { id: 't1' },
|
params: { id: 't1' },
|
||||||
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
|
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
|
||||||
|
request: new Request('http://localhost/admin/tags/t1'),
|
||||||
url
|
url
|
||||||
} as never);
|
} as never);
|
||||||
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(true);
|
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(true);
|
||||||
@@ -31,6 +32,7 @@ describe('tags/[id] — load function', () => {
|
|||||||
const result = await load({
|
const result = await load({
|
||||||
params: { id: 't1' },
|
params: { id: 't1' },
|
||||||
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
|
parent: async () => ({ tags: [{ id: 't1', name: 'Test', documentCount: 0 }] }),
|
||||||
|
request: new Request('http://localhost/admin/tags/t1'),
|
||||||
url
|
url
|
||||||
} as never);
|
} as never);
|
||||||
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(false);
|
expect((result as { mergeSuccess: boolean }).mergeSuccess).toBe(false);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user