- Add @BatchSize(50) fallback comments on findBySenderId / findByReceiversId
- Replace silent size() discard in getRecentActivity test with assertThat isNotEmpty()
- Add ADR-022 reference comment above @JsonIgnoreProperties on Person and Tag
- Document within-open-transaction limitation in DocumentLazyLoadingTest Javadoc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Refactor DocumentLazyLoadingTest: pull value assertions (assertThat) out
of assertThatCode lambdas so failures surface as AssertionError rather
than "unexpected exception: AssertionError" (review item 1)
- Add @EntityGraph("Document.full") to findBySenderId, findByReceiversId,
findConversation, and findSinglePersonCorrespondence — all return full
Documents to the controller for JSON serialization (review item 2)
- Add "// Callers access only ..." comments to un-graphed methods where no
lazy associations are touched: findByTags_Id, findByStatus,
findByMetadataCompleteFalse(Sort), findByMetadataCompleteFalse(Pageable)
- Remove "what" inline comments from @Transactional(readOnly=true)
on getRecentActivity and getDocumentById — the why is in ADR-022 (item 4)
- Add named-graph coupling consequence to ADR-022: Document.java and
DocumentRepository.java graph name strings must stay in sync (item 5)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The convention 'read methods are not annotated' has one exception: methods
that return lazily-initialized entities to callers require readOnly=true to
keep the session open. Documents the rule and links to ADR-022.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace repeated personRepository.save/tagRepository.save/documentRepository.save
boilerplate with savedPerson(), savedTag(), savedDocument() helpers.
Each test body is now 2-3 lines of relevant setup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
List<Document> findAll(Specification) is called in DocumentService for
receiver-sort, sender-sort, and conversation queries but had no query-count
coverage. Asserts ≤5 statements for 5 docs with @EntityGraph(Document.list).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
assertThatCode(() -> service.searchDocuments(...)) passed vacuously on an
empty page; capture the result, assert totalElements > 0, then assert
getSender().getLastName() is accessible post-return.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without setStatisticsEnabled(true) the counter stays 0 and ≤2 passes
vacuously when the test runs in isolation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hibernate throws AnnotationException at startup when @BatchSize is placed
on a @ManyToOne field. @BatchSize is only valid on collections (@OneToMany,
@ManyToMany, @ElementCollection). The N+1 for sender is already covered by
the @EntityGraph overrides on DocumentRepository.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous version only asserted the method call didn't throw. Now the test
captures the returned list and asserts that sender.getLastName() and
tags.size() are accessible outside the transaction, which is the scenario
that would have failed with a LazyInitializationException.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These annotations deviate from the project convention (read methods are
normally unannotated). The comment explains that the session must stay
open for callers to access lazy-loaded collections post-return, preventing
future developers from removing the annotation as a cleanup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Consistent with the @BatchSize already on receivers and tags. Any lazy
code path not covered by an entity graph will batch-load these associations
instead of issuing one query per document.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getRecentActivity calls findAll(Pageable) — the JpaRepository overload
not covered by the existing Specification variants. Without this override,
sender is loaded N+1 per document. Now applies Document.list graph so
sender and tags are fetched eagerly for every findAll(Pageable) call.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds failing test: findAll(Pageable) must not N+1 sender for 5 docs.
Without @EntityGraph override for this overload, each document triggers
a separate SELECT for its lazy sender.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stats tracking is already enabled per-test via setStatisticsEnabled(true);
enabling it globally added unnecessary overhead to every test in the suite.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Five integration tests verify that DocumentService and DashboardService
do not throw LazyInitializationException after the EAGER→LAZY migration:
getDocumentById, getRecentActivity, searchDocuments (receiver/sender sort),
and dashboardService.getResume.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- getDocumentById: add @Transactional(readOnly=true) — calls
tagService.resolveEffectiveColors(doc.getTags()) which requires an open
session after the LAZY switch
- getRecentActivity: add @Transactional(readOnly=true) — callers may access
tags/receivers on the returned list; keeps session open for @BatchSize fetches
- updateDocumentTags: add @Transactional — write method was missing annotation
Also adds @JsonIgnoreProperties({"hibernateLazyInitializer","handler"}) to
Person and Tag to prevent Jackson serialization errors on uninitialized
lazy proxies.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- receivers, tags, trainingLabels: FetchType.EAGER → FetchType.LAZY
- sender: add explicit FetchType.LAZY (was implicitly lazy, now explicit)
- @NamedEntityGraph("Document.full"): sender + receivers + tags
- @NamedEntityGraph("Document.list"): sender + tags
- DocumentRepository.findById overridden with @EntityGraph("Document.full")
- DocumentRepository.findAll(Specification, Pageable) overridden with
@EntityGraph("Document.list")
- DocumentRepository.findAll(Specification) overridden with
@EntityGraph("Document.list") for RECEIVER/SENDER sort paths
- @BatchSize(50) on receivers and tags as fallback for any list path
that does not go through an @EntityGraph method
Fixes issue #467.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds Hibernate statistics to the test config and two new tests in
DocumentRepositoryTest:
- findAll_withSpecAndPageable asserts ≤5 statements for 10 documents
(currently RED: EAGER @ManyToMany generates 31 secondary SELECTs)
- findById regression guard verifies collections load in ≤2 statements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- frontend/hooks.server.ts: replace request.url.includes('/api/') with
new URL(request.url).pathname.startsWith('/api/') so a page named
/my-api/something cannot accidentally match the API gate
- DomainException: add optional retryAfterSeconds field and a new
tooManyRequests() factory overload that carries the value
- LoginRateLimiter: pass windowMinutes * 60 as retryAfterSeconds when
throwing TOO_MANY_LOGIN_ATTEMPTS (RFC 6585 §4 SHOULD)
- GlobalExceptionHandler: emit Retry-After header when retryAfterSeconds
is set on a DomainException
- RateLimitInterceptor: emit Retry-After: 60 on 429 responses (1-min
window matches the existing MAX_REQUESTS_PER_MINUTE logic)
- LoginRateLimiterTest: assert retryAfterSeconds equals window duration
- RateLimitInterceptorTest: assert Retry-After header is set on 429
- JdbcSessionRevocationAdapterIntegrationTest: new @SpringBootTest +
Testcontainers test verifying revokeAll deletes all spring_session rows
and revokeOther leaves the current session intact
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove unreachable `&& !xsrfToken` condition from `handleFetch` guard;
simplify the redundant `cookieParts.length > 0` check that follows it
- Add `TOO_MANY_LOGIN_ATTEMPTS` to both Error Handling sections in CLAUDE.md
(backend and frontend) so LLMs are aware of the code without looking it up
- Add reverse-proxy IP trust and IPv6 address-cycling caveats to ADR-022
Consequences section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bucket4j-core 8.10.1 is manually pinned in pom.xml outside the Spring BOM.
Adds a packageRules entry so Renovate tracks it: patch updates auto-merge,
minor/major updates open PRs for manual review.
Addresses Tobias Concern 1 from PR #617 review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renders LoginPage with form.rateLimited=true and asserts that the
role="alert" div (clock icon + error message) is visible in the browser.
Previously only the form action's rateLimited=true return value was tested;
now the rendered UI is also verified.
Addresses Sara Concern 4 / Elicit open question from PR #617 review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Integration test:
- Adds post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING to
AuthSessionIntegrationTest, verifying CSRF is active end-to-end (not just
in @WebMvcTest slices).
SessionRevocationConfig (new):
- Replaces fragile @ConditionalOnBean/@ConditionalOnMissingBean on @Service
beans with a single @Configuration @Bean method that accepts
JdbcIndexedSessionRepository as @Autowired(required=false). Spring
resolves the optional parameter reliably after auto-configuration fires,
choosing JdbcSessionRevocationAdapter when available and
NoOpSessionRevocationAdapter otherwise.
- JdbcSessionRevocationAdapter and NoOpSessionRevocationAdapter are now
plain implementation classes (no @Service/@Conditional annotations).
Addresses Sara Concern 2 from PR #617 review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds two @WebMvcTest assertions verifying that POST /api/users/me/password
and POST /api/users/{id}/force-logout without an XSRF-TOKEN header return
403 with code CSRF_TOKEN_MISSING.
Addresses Nora Concern 9 from PR #617 review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UserControllerTest: replaces fully-qualified org.mockito.Mockito.verify() and
ArgumentMatchers.eq() with the static imports already present in the file.
LoginRateLimiterTest: replaces three org.assertj.core.api.Assertions.assertThat()
calls with the static-import form; adds missing assertThat import.
Addresses Felix Suggestions 2 and 4 from PR #617 review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove stale "CSRF protection is disabled" claim; describe the double-submit
cookie pattern now in use (CookieCsrfTokenRepository + X-XSRF-TOKEN header)
- Link to ADR-022 for the full rationale
- Add CSRF_TOKEN_MISSING and TOO_MANY_LOGIN_ATTEMPTS to the exception row
Fixes Markus's blocker.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract SessionRevocationPort interface with JdbcSessionRevocationAdapter
(@ConditionalOnBean) and NoOpSessionRevocationAdapter (@ConditionalOnMissingBean).
AuthService now uses @RequiredArgsConstructor with final fields for both
LoginRateLimiter and SessionRevocationPort, removing all null guards.
AuthServiceTest drops ReflectionTestUtils.setField and uses @Mock on the port.
Fixes Felix's blocker: @Autowired(required=false) field injection in AuthService.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The circular-dependency that originally forced @AllArgsConstructor was
removed when changePassword orchestration moved into the controller.
No cycle now exists between UserController, UserService, AuthService,
or AuditService — final fields and constructor injection are safe again.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Case variants of the same address (e.g. User@EXAMPLE.COM vs user@example.com)
now share a single Bucket4j bucket, preventing a trivial bypass of per-email
limits via mixed-case submissions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove stale "CSRF is disabled pending #524" note; update secFilter
description to reflect the enabled double-submit cookie pattern.
Add LoginRateLimiter and RateLimitProperties components with their
relationships to AuthService. Update frontend→secFilter rel to show
X-XSRF-TOKEN header.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Regular error div was missing role="alert" — screen readers did not
announce it on dynamic display. Rate-limited clock icon used text-ink-3
(muted grey) instead of text-red-600, visually inconsistent with the
surrounding error text. Also removes the erroneous aria-invalid="true"
from the rate-limit alert div (not a permitted attribute on role=alert).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extends the diagram from ADR-020 Phase 1 to cover:
- Rate limiter gate before credential validation in login
- CSRF double-submit cookie handshake for mutating requests
- Session revocation on password change (revokeOtherSessions) and
password reset (revokeAllSessions)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents the double-submit cookie CSRF pattern, sequential token-bucket
rate limiter with refund mechanic, and session revocation on password
change/reset — all implemented as part of issue #524.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces per-invocation new ObjectMapper() in the accessDeniedHandler
lambda with a static field (avoids repeated allocation). ObjectMapper
cannot be injected in SecurityConfig because @WebMvcTest slices exclude
JacksonAutoConfiguration; the static instance is safe since the response
only serialises fixed String keys.
Also corrects the ADR cross-reference in the CSRF comment from ADR-020
(Spring Session JDBC) to ADR-022 (CSRF + session revocation).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds regression coverage for the custom accessDeniedHandler in
SecurityConfig: a POST without X-XSRF-TOKEN returns 403 with error
code CSRF_TOKEN_MISSING, not a generic Spring 403.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses Felix (blocker 1): the old implementation consumed from both buckets
before checking either result, silently eroding the per-email quota when only the
per-IP limit was blocking. The fix checks ipEmail first, then IP; on IP failure it
refunds the ipEmail token so legitimate users behind a shared IP are not penalised.
Also adds two new test cases:
- different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion (Sara)
- ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts (red → green)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses Nora (blocker 1) and Felix (suggestion): both revocation methods
now return 0 immediately when sessionRepository is unavailable (non-web
test contexts where JdbcHttpSessionAutoConfiguration does not fire).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- handleFetch injects X-XSRF-TOKEN + XSRF-TOKEN cookie on all mutating
backend API requests (double-submit cookie pattern); generates a fresh
UUID when no XSRF-TOKEN cookie exists yet
- ErrorCode union gains CSRF_TOKEN_MISSING and TOO_MANY_LOGIN_ATTEMPTS;
getErrorMessage maps both to i18n keys
- de/en/es messages add error_csrf_token_missing and
error_too_many_login_attempts translations
- Login action maps HTTP 429 to fail(429, { ..., rateLimited: true });
page shows a muted clock icon with aria-invalid on rate-limit errors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LoginRateLimiter uses two Caffeine LoadingCaches of Bucket4j buckets —
one keyed on IP:email (10 attempts/15 min) and one on IP alone (20/15 min
backstop). Exceeding either throws DomainException(TOO_MANY_LOGIN_ATTEMPTS)
and emits LOGIN_RATE_LIMITED audit. Successful login invalidates both
buckets via invalidateOnSuccess. Buckets expire after windowMinutes of
inactivity (no clock advance needed — Caffeine handles eviction).
AuthService integrates it as an optional @Autowired field so non-web
test contexts still work without a Caffeine dependency.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After updating the user password during a reset flow, calls
authService.revokeAllSessions(email) to invalidate every active session
for the account — prevents an attacker with a stolen session from
retaining access after the owner resets their password.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
changePassword now calls authService.revokeOtherSessions() after the
password is updated and emits a LOGOUT audit with reason=password_change.
POST /api/users/{id}/force-logout (ADMIN_USER permission) revokes all
sessions for the target user and emits ADMIN_FORCE_LOGOUT audit. Returns
{"revokedCount": N} with 200.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Uses JdbcIndexedSessionRepository (optional field — null-safe in non-web
test contexts) to delete all sessions for a principal except the current
one (revokeOtherSessions) or all sessions unconditionally (revokeAllSessions).
Both methods return the count of deleted sessions for audit payloads.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Re-enables Spring Security's CSRF filter (was disabled with a TODO comment).
Uses CookieCsrfTokenRepository so the frontend can read the XSRF-TOKEN
cookie and send it as X-XSRF-TOKEN on state-mutating requests.
Returns CSRF_TOKEN_MISSING error code on 403 instead of generic FORBIDDEN.
Updates all WebMvcTest classes to include .with(csrf()) on POST/PUT/PATCH/
DELETE/multipart requests, and fixes integration tests to supply the
XSRF-TOKEN cookie + header directly (lazy generation in Spring Security 7).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Quoting RESOLVE as a string and expanding with "$RESOLVE" passes the
flag and its value as a single token to curl; curl rejects the whole
string as an unknown option (exit 2). Switching to a Bash array and
"${RESOLVE[@]}" ensures the two words are always passed as separate
arguments regardless of quoting context.
Also aligns release.yml gateway detection with nightly.yml: replaces
`ip route` (requires iproute2) with /proc/net/route (always available
in the job container, no extra package needed).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Explains what ocr-volume-init does (chown volumes + create TMPDIR), how to
verify it succeeded (docker logs), and what failure looks like. Addresses
reviewer concerns from @mkeller and @tobiwendt on PR #615.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
alpine:3 is a moving tag — pinning to 3.21 makes builds reproducible and
rollbacks possible. networks: [] removes the init container from the project
network since it only needs volume access, not network access (least privilege).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- entrypoint.sh: replace "cross-job ground-truth leakage" with plain
"Remove stale partial downloads left by a previous docker-kill"
- test_tmpdir_is_inside_persistent_cache_volume: add docker exec command
so future developers know how to run this deployment-contract test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test_entrypoint_removes_day_old_orphans and test_entrypoint_preserves_fresh_files
verify the find -mtime +1 -delete logic using os.utime() to fabricate old mtimes
without mocking system time. Also extracts _run_entrypoint helper to remove
subprocess setup duplication.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>