fix(backend): resolve cross-domain repo + controller→repo violations before REFACTOR-1 #417

Closed
opened 2026-05-04 16:41:08 +02:00 by marcel · 8 comments
Owner

Context

Precursor for REFACTOR-1 (#407) under Epic #406. Surfaced by AUDIT-2 (#389) as C6.1 + C6.2 Critical fails.

REFACTOR-1 is supposed to be a purely mechanical package move. If the layering violations below are still in the tree when REFACTOR-1 runs, two bad things happen:

  1. The post-refactor shared/ArchitectureTest.java ArchUnit rule (REFACTOR-3 #409) fails on day one, so REFACTOR-1's "tests green" signal is meaningless.
  2. The package move surfaces these violations as cross-package imports, masking the real fix and tempting whoever fixes the test to add the wrong dependency direction.

Therefore the violations must be resolved before REFACTOR-1's branch is created.

Findings (audit-confirmed, file:line precise)

C6.1 — Controllers injecting repositories directly (2 sites)

# File Repository injected Fix
1 backend/src/main/java/org/raddatz/familienarchiv/controller/StatsController.java:18-19 PersonRepository, DocumentRepository Introduce StatsService (or fold into existing DashboardService); controller delegates personRepository.count() + documentRepository.count() to it. While there, add @RequirePermission(Permission.READ_ALL) (currently missing — flagged by AUDIT-2 §7).
2 backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java:27 PasswordResetTokenRepository Add getResetTokenForTest(String email) method to PasswordResetService gated on @Profile("e2e"); call from controller.

C6.2 — Services injecting another domain's repository (11 sites)

# Service file:line Foreign repository Owning domain Fix
1 service/MassImportService.java:58 DocumentRepository document Route through DocumentService write API
2 service/ThumbnailService.java:65 DocumentRepository document Add DocumentService.findIdsForThumbnailBackfill() + updateThumbnailMetadata()
3 service/ThumbnailBackfillService.java:40 DocumentRepository document Same as above
4 service/ThumbnailAsyncRunner.java:32 DocumentRepository document Same as above
5 service/TranscriptionQueueService.java:23 DocumentRepository document Add read-only DocumentService.queueProjection(...)
6 service/SegmentationTrainingExportService.java:30-32 TranscriptionBlockRepository, AnnotationRepository, DocumentRepository document.transcription + document.annotation + document Service-level read methods on each owning service (counts as 3 violations even though one file)
7 service/TrainingDataExportService.java:31-33 TranscriptionBlockRepository, AnnotationRepository, DocumentRepository same triple Same as above
8 service/SenderModelService.java:35 TranscriptionBlockRepository document.transcription Read API on TranscriptionService
9 service/OcrTrainingService.java:40 TranscriptionBlockRepository document.transcription Same as above
10 service/TranscriptionService.java:40 AnnotationRepository document.annotation Read API on AnnotationService
11 service/AnnotationService.java:28 TranscriptionBlockRepository document.transcription Read API on TranscriptionService
12 service/PasswordResetService.java:33 AppUserRepository user Inject UserService.findByEmail/save

The audit summarised this as "11 cross-domain repo injections" because items 6 and 7 each carry 3 distinct foreign repos but the audit collapsed each multi-repo file into a single line. Twelve concrete private final XRepository lines map to 11 distinct file→domain violations — both interpretations should reach zero.

Method (per violation)

For each row in the tables above:

  1. Identify the owning domain's service. It is named after the repository's entity (DocumentRepositoryDocumentService, TranscriptionBlockRepositoryTranscriptionService, AnnotationRepositoryAnnotationService, AppUserRepositoryUserService, PasswordResetTokenRepositoryPasswordResetService).
  2. Add the missing service method. Read-only callers get a query method (findIdsForThumbnailBackfill(), queueProjection(...), etc.); write callers get a write helper. Keep the method signature minimal — return what the caller actually consumes.
  3. Replace the repository injection in the violator with the owning service injection. Delete the now-unused import.
  4. Run ./mvnw test. Must stay green after every fix.
  5. Commit per violation (or per closely-related cluster) so the PR reviews cleanly.

Adding new service methods is allowed; this issue is a layering fix, not a "change behavior" fix. Do not introduce new abstractions, new DTOs, or new endpoints.

Acceptance criteria

  • All twelve repository-injection lines listed above are gone; each violator instead injects the owning domain's service.
  • StatsController no longer injects any repository (use a new or existing service).
  • AuthE2EController no longer injects PasswordResetTokenRepository.
  • Verification grep returns zero hits in service code referring to a foreign domain's repository:
    grep -rn "private final.*Repository " backend/src/main/java/org/raddatz/familienarchiv/service/ \
      | awk -F: '{print $1, $3}' \
      | grep -v -E "(DocumentService.*DocumentRepository|DocumentService.*DocumentVersionRepository|PersonService.*Person.*Repository|TagService.*TagRepository|UserService.*(AppUserRepository|UserGroupRepository)|TranscriptionService.*(TranscriptionBlock|TranscriptionBlockVersion)Repository|TranscriptionBlockQueryService.*TranscriptionBlockRepository|AnnotationService.*AnnotationRepository|CommentService.*CommentRepository|GeschichteService.*GeschichteRepository|NotificationService.*NotificationRepository|OcrService.*Ocr.*Repository|OcrBatchService.*Ocr.*Repository|OcrAsyncRunner.*Ocr.*Repository|OcrTrainingService.*OcrTrainingRunRepository|SenderModelService.*SenderModelRepository|SenderModelService.*OcrTrainingRunRepository|InviteService.*InviteTokenRepository|PasswordResetService.*PasswordResetTokenRepository|UserSearchService.*AppUserRepository|CustomUserDetailsService.*AppUserRepository|DocumentVersionService.*DocumentVersionRepository)"
    
    (The allowlist is each service paired only with its own-domain repositories.)
  • Verification grep returns zero hits in controller code referring to any repository:
    grep -rn "Repository" backend/src/main/java/org/raddatz/familienarchiv/controller/
    
  • ./mvnw test is green.
  • Backend OpenAPI spec is unchanged (regenerate with --spring.profiles.active=dev and diff /v3/api-docs — should be empty).

Definition of Done

  • PR opened against main, reviewed, and merged before REFACTOR-1's (#407) branch is created.
  • A comment on #407 noting this issue is closed.
  • A comment on this issue noting the violation count went from 13 → 0.

Cross-references

  • Source: AUDIT-2 (#389) §3 (C6.1 + C6.2) and §9 (Prerequisites for big-bang refactor).
  • Blocks: REFACTOR-1 (#407).
  • Indirectly enables: REFACTOR-3 (#409) — the ArchUnit ArchitectureTest rule that codifies these constraints.
  • Related milestone: #7 Codebase Legibility.
## Context Precursor for **REFACTOR-1 (#407)** under **Epic #406**. Surfaced by **AUDIT-2 (#389)** as **C6.1 + C6.2 Critical fails**. REFACTOR-1 is supposed to be a *purely mechanical* package move. If the layering violations below are still in the tree when REFACTOR-1 runs, two bad things happen: 1. The post-refactor `shared/ArchitectureTest.java` ArchUnit rule (REFACTOR-3 #409) fails on day one, so REFACTOR-1's "tests green" signal is meaningless. 2. The package move surfaces these violations as cross-package imports, masking the real fix and tempting whoever fixes the test to add the wrong dependency direction. Therefore the violations must be resolved **before** REFACTOR-1's branch is created. ## Findings (audit-confirmed, file:line precise) ### C6.1 — Controllers injecting repositories directly (2 sites) | # | File | Repository injected | Fix | |---|---|---|---| | 1 | `backend/src/main/java/org/raddatz/familienarchiv/controller/StatsController.java:18-19` | `PersonRepository`, `DocumentRepository` | Introduce `StatsService` (or fold into existing `DashboardService`); controller delegates `personRepository.count()` + `documentRepository.count()` to it. While there, add `@RequirePermission(Permission.READ_ALL)` (currently missing — flagged by AUDIT-2 §7). | | 2 | `backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java:27` | `PasswordResetTokenRepository` | Add `getResetTokenForTest(String email)` method to `PasswordResetService` gated on `@Profile("e2e")`; call from controller. | ### C6.2 — Services injecting another domain's repository (11 sites) | # | Service file:line | Foreign repository | Owning domain | Fix | |---|---|---|---|---| | 1 | `service/MassImportService.java:58` | `DocumentRepository` | document | Route through `DocumentService` write API | | 2 | `service/ThumbnailService.java:65` | `DocumentRepository` | document | Add `DocumentService.findIdsForThumbnailBackfill()` + `updateThumbnailMetadata()` | | 3 | `service/ThumbnailBackfillService.java:40` | `DocumentRepository` | document | Same as above | | 4 | `service/ThumbnailAsyncRunner.java:32` | `DocumentRepository` | document | Same as above | | 5 | `service/TranscriptionQueueService.java:23` | `DocumentRepository` | document | Add read-only `DocumentService.queueProjection(...)` | | 6 | `service/SegmentationTrainingExportService.java:30-32` | `TranscriptionBlockRepository`, `AnnotationRepository`, `DocumentRepository` | document.transcription + document.annotation + document | Service-level read methods on each owning service (counts as 3 violations even though one file) | | 7 | `service/TrainingDataExportService.java:31-33` | `TranscriptionBlockRepository`, `AnnotationRepository`, `DocumentRepository` | same triple | Same as above | | 8 | `service/SenderModelService.java:35` | `TranscriptionBlockRepository` | document.transcription | Read API on `TranscriptionService` | | 9 | `service/OcrTrainingService.java:40` | `TranscriptionBlockRepository` | document.transcription | Same as above | | 10 | `service/TranscriptionService.java:40` | `AnnotationRepository` | document.annotation | Read API on `AnnotationService` | | 11 | `service/AnnotationService.java:28` | `TranscriptionBlockRepository` | document.transcription | Read API on `TranscriptionService` | | 12 | `service/PasswordResetService.java:33` | `AppUserRepository` | user | Inject `UserService.findByEmail/save` | > The audit summarised this as "11 cross-domain repo injections" because items 6 and 7 each carry 3 distinct foreign repos but the audit collapsed each multi-repo file into a single line. Twelve concrete `private final XRepository` lines map to 11 distinct file→domain violations — both interpretations should reach zero. ## Method (per violation) For each row in the tables above: 1. **Identify the owning domain's service.** It is named after the repository's entity (`DocumentRepository` → `DocumentService`, `TranscriptionBlockRepository` → `TranscriptionService`, `AnnotationRepository` → `AnnotationService`, `AppUserRepository` → `UserService`, `PasswordResetTokenRepository` → `PasswordResetService`). 2. **Add the missing service method.** Read-only callers get a query method (`findIdsForThumbnailBackfill()`, `queueProjection(...)`, etc.); write callers get a write helper. Keep the method signature minimal — return what the caller actually consumes. 3. **Replace the repository injection** in the violator with the owning service injection. Delete the now-unused import. 4. **Run `./mvnw test`.** Must stay green after every fix. 5. **Commit per violation** (or per closely-related cluster) so the PR reviews cleanly. Adding new service methods is allowed; this issue is a layering fix, not a "change behavior" fix. Do not introduce new abstractions, new DTOs, or new endpoints. ## Acceptance criteria - [ ] All twelve repository-injection lines listed above are gone; each violator instead injects the owning domain's service. - [ ] `StatsController` no longer injects any repository (use a new or existing service). - [ ] `AuthE2EController` no longer injects `PasswordResetTokenRepository`. - [ ] **Verification grep returns zero hits in service code referring to a foreign domain's repository:** ```bash grep -rn "private final.*Repository " backend/src/main/java/org/raddatz/familienarchiv/service/ \ | awk -F: '{print $1, $3}' \ | grep -v -E "(DocumentService.*DocumentRepository|DocumentService.*DocumentVersionRepository|PersonService.*Person.*Repository|TagService.*TagRepository|UserService.*(AppUserRepository|UserGroupRepository)|TranscriptionService.*(TranscriptionBlock|TranscriptionBlockVersion)Repository|TranscriptionBlockQueryService.*TranscriptionBlockRepository|AnnotationService.*AnnotationRepository|CommentService.*CommentRepository|GeschichteService.*GeschichteRepository|NotificationService.*NotificationRepository|OcrService.*Ocr.*Repository|OcrBatchService.*Ocr.*Repository|OcrAsyncRunner.*Ocr.*Repository|OcrTrainingService.*OcrTrainingRunRepository|SenderModelService.*SenderModelRepository|SenderModelService.*OcrTrainingRunRepository|InviteService.*InviteTokenRepository|PasswordResetService.*PasswordResetTokenRepository|UserSearchService.*AppUserRepository|CustomUserDetailsService.*AppUserRepository|DocumentVersionService.*DocumentVersionRepository)" ``` (The allowlist is each service paired only with its own-domain repositories.) - [ ] **Verification grep returns zero hits in controller code referring to any repository:** ```bash grep -rn "Repository" backend/src/main/java/org/raddatz/familienarchiv/controller/ ``` - [ ] `./mvnw test` is green. - [ ] Backend OpenAPI spec is unchanged (regenerate with `--spring.profiles.active=dev` and diff `/v3/api-docs` — should be empty). ## Definition of Done - PR opened against `main`, reviewed, and **merged before REFACTOR-1's (#407) branch is created.** - A comment on #407 noting this issue is closed. - A comment on this issue noting the violation count went from 13 → 0. ## Cross-references - Source: AUDIT-2 (#389) §3 (C6.1 + C6.2) and §9 (Prerequisites for big-bang refactor). - Blocks: REFACTOR-1 (#407). - Indirectly enables: REFACTOR-3 (#409) — the ArchUnit `ArchitectureTest` rule that codifies these constraints. - Related milestone: #7 Codebase Legibility.
marcel added this to the (deleted) milestone 2026-05-04 16:41:08 +02:00
Author
Owner

🏗️ Markus Keller — Senior Application Architect

Observations

This is exactly the right fix to run before REFACTOR-1 — doing the package move with these violations live would have caused the ArchUnit rules to fail on the first commit and obscured what was actually wrong.

Looking at the code, the violations split into three different risk tiers:

Tier 1 — Simple and safe: StatsController and PasswordResetService injecting AppUserRepository. Both are minimal operations (count(), findByEmail). Introducing a StatsService (a 5-line class) and routing PasswordResetService through UserService.findByEmail() is a one-commit job with no behavioral risk.

Tier 2 — Careful interface design: The Thumbnail cluster (ThumbnailService, ThumbnailBackfillService, ThumbnailAsyncRunner) all inject DocumentRepository directly. ThumbnailAsyncRunner.generateAsync() calls documentRepository.findById(documentId) which is the canonical DocumentService.getById() — but the async runner explicitly must not throw on missing documents (it logs and returns). The new DocumentService method needs a nullable/Optional variant (findByIdOrEmpty(UUID)) rather than the throwing getById(). Get this signature right on the first cut; changing it later requires touching the caller again.

Tier 3 — Circular dependency risk: TranscriptionService injects AnnotationRepository. AnnotationService injects TranscriptionBlockRepository. These two services have a mutual read dependency. The fix (read methods on each owning service) is correct, but verify that Spring does not flag a circular bean dependency when both services inject each other. If it does, the path through TranscriptionBlockQueryService (already in the codebase for similar read queries) is the right escape valve — add the needed method there rather than creating a cycle.

AuthE2EController: The fix described in the issue (move getResetTokenForTest to PasswordResetService behind @Profile("e2e")) is correct. One constraint to watch: @Profile("e2e") on a service method is not how Spring works — @Profile gates bean creation, not individual methods. The right pattern is a separate @Service @Profile("e2e") class PasswordResetTestHelper that wraps PasswordResetService and exposes getResetTokenForTest(String email). The controller then injects that helper, and the production bean graph never sees it.

Recommendations

  • Define the DocumentService read method as Optional<Document> findById(UUID id) (no-throw variant). Use this in ThumbnailAsyncRunner — the isEmpty() guard already exists.
  • For the TranscriptionService ↔ AnnotationService mutual dependency: add the needed read methods to TranscriptionBlockQueryService instead. It already exists for this purpose and avoids the circular injection risk.
  • Create PasswordResetTestHelper as a @Service @Profile("e2e") bean rather than annotating a method on the production PasswordResetService.
  • Commit order matters: fix StatsController first (simplest, proves the commit-per-violation discipline), then work through Tier 2, then Tier 3 last when you understand the circular dependency resolution.
  • Run ./mvnw dependency:analyze after the refactor to verify no unused direct dependencies remain in the affected services.
## 🏗️ Markus Keller — Senior Application Architect ### Observations This is exactly the right fix to run before REFACTOR-1 — doing the package move with these violations live would have caused the ArchUnit rules to fail on the first commit and obscured what was actually wrong. Looking at the code, the violations split into three different risk tiers: **Tier 1 — Simple and safe:** `StatsController` and `PasswordResetService` injecting `AppUserRepository`. Both are minimal operations (`count()`, `findByEmail`). Introducing a `StatsService` (a 5-line class) and routing `PasswordResetService` through `UserService.findByEmail()` is a one-commit job with no behavioral risk. **Tier 2 — Careful interface design:** The Thumbnail cluster (`ThumbnailService`, `ThumbnailBackfillService`, `ThumbnailAsyncRunner`) all inject `DocumentRepository` directly. `ThumbnailAsyncRunner.generateAsync()` calls `documentRepository.findById(documentId)` which is the canonical `DocumentService.getById()` — but the async runner explicitly must not throw on missing documents (it logs and returns). The new `DocumentService` method needs a nullable/Optional variant (`findByIdOrEmpty(UUID)`) rather than the throwing `getById()`. Get this signature right on the first cut; changing it later requires touching the caller again. **Tier 3 — Circular dependency risk:** `TranscriptionService` injects `AnnotationRepository`. `AnnotationService` injects `TranscriptionBlockRepository`. These two services have a mutual read dependency. The fix (read methods on each owning service) is correct, but verify that Spring does not flag a circular bean dependency when both services inject each other. If it does, the path through `TranscriptionBlockQueryService` (already in the codebase for similar read queries) is the right escape valve — add the needed method there rather than creating a cycle. **`AuthE2EController`:** The fix described in the issue (move `getResetTokenForTest` to `PasswordResetService` behind `@Profile("e2e")`) is correct. One constraint to watch: `@Profile("e2e")` on a service method is not how Spring works — `@Profile` gates bean creation, not individual methods. The right pattern is a separate `@Service @Profile("e2e") class PasswordResetTestHelper` that wraps `PasswordResetService` and exposes `getResetTokenForTest(String email)`. The controller then injects that helper, and the production bean graph never sees it. ### Recommendations - Define the `DocumentService` read method as `Optional<Document> findById(UUID id)` (no-throw variant). Use this in `ThumbnailAsyncRunner` — the `isEmpty()` guard already exists. - For the `TranscriptionService ↔ AnnotationService` mutual dependency: add the needed read methods to `TranscriptionBlockQueryService` instead. It already exists for this purpose and avoids the circular injection risk. - Create `PasswordResetTestHelper` as a `@Service @Profile("e2e")` bean rather than annotating a method on the production `PasswordResetService`. - Commit order matters: fix `StatsController` first (simplest, proves the commit-per-violation discipline), then work through Tier 2, then Tier 3 last when you understand the circular dependency resolution. - Run `./mvnw dependency:analyze` after the refactor to verify no unused direct dependencies remain in the affected services.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

The issue spec is unusually precise — file:line citations, an explicit allowlist grep, and a zero-hit acceptance condition. That's the right level of rigor for a mechanical refactor. Good.

Existing tests will break and need updates. I looked at the affected test files:

  • ThumbnailServiceTest directly instantiates ThumbnailService(fileService, s3Client, documentRepository). When DocumentRepository is removed from ThumbnailService and replaced with a DocumentService method, the constructor changes and every test that wires it via new ThumbnailService(...) or ReflectionTestUtils needs updating.
  • MassImportServiceTest mocks @Mock DocumentRepository documentRepository — same problem, that @Mock becomes stale once the injection is removed from MassImportService.
  • StatsControllerTest mocks @MockitoBean PersonRepository personRepository and @MockitoBean DocumentRepository documentRepository. Once StatsController delegates to StatsService, these @MockitoBean declarations switch to @MockitoBean StatsService statsService. The test structure stays the same but the mock target changes.
  • TranscriptionQueueServiceTest and PasswordResetServiceTest — check and update similarly.

Per-violation commit discipline. The issue says "commit per violation or per closely-related cluster." The Thumbnail cluster (ThumbnailService, ThumbnailBackfillService, ThumbnailAsyncRunner) all need the same DocumentService method to be added, so they should land in one commit. Everything else is independent.

ThumbnailService is not @RequiredArgsConstructor — it has an explicit constructor. When you add DocumentService to its dependencies, update the constructor manually. Don't switch it to @RequiredArgsConstructor as a side-effect of this issue — that's a behavior-neutral change but outside this issue's scope.

PasswordResetService already injects AppUserRepository — not a foreign-domain repository. PasswordResetService owns password-reset tokens; AppUser is in the user domain. The fix (inject UserService instead) is correct by the layering rule, but verify UserService doesn't already inject PasswordResetTokenRepository creating a reverse dependency before you wire it.

Recommendations

  • Write the new DocumentService read method as a failing test first: DocumentServiceTest#findById_returnsEmpty_whenDocumentDoesNotExist(). Then implement. Then update the Thumbnail service tests to mock DocumentService instead of DocumentRepository.
  • The StatsService implementation is 5 lines: two count() calls delegated from repository to service. TDD: write StatsServiceTest#getStats_returnsCountsFromRepositories() as the first failing test.
  • Before touching any production code, run ./mvnw test and confirm the current suite is green. Record the baseline. Every commit after that must also be green.
  • The PR description should list the before/after grep counts: "Repository injections in service code: 13 → 0. Repository injections in controller code: 3 → 0." This makes the review trivial.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations The issue spec is unusually precise — file:line citations, an explicit allowlist grep, and a zero-hit acceptance condition. That's the right level of rigor for a mechanical refactor. Good. **Existing tests will break and need updates.** I looked at the affected test files: - `ThumbnailServiceTest` directly instantiates `ThumbnailService(fileService, s3Client, documentRepository)`. When `DocumentRepository` is removed from `ThumbnailService` and replaced with a `DocumentService` method, the constructor changes and every test that wires it via `new ThumbnailService(...)` or `ReflectionTestUtils` needs updating. - `MassImportServiceTest` mocks `@Mock DocumentRepository documentRepository` — same problem, that `@Mock` becomes stale once the injection is removed from `MassImportService`. - `StatsControllerTest` mocks `@MockitoBean PersonRepository personRepository` and `@MockitoBean DocumentRepository documentRepository`. Once `StatsController` delegates to `StatsService`, these `@MockitoBean` declarations switch to `@MockitoBean StatsService statsService`. The test structure stays the same but the mock target changes. - `TranscriptionQueueServiceTest` and `PasswordResetServiceTest` — check and update similarly. **Per-violation commit discipline.** The issue says "commit per violation or per closely-related cluster." The Thumbnail cluster (`ThumbnailService`, `ThumbnailBackfillService`, `ThumbnailAsyncRunner`) all need the same `DocumentService` method to be added, so they should land in one commit. Everything else is independent. **`ThumbnailService` is not `@RequiredArgsConstructor`** — it has an explicit constructor. When you add `DocumentService` to its dependencies, update the constructor manually. Don't switch it to `@RequiredArgsConstructor` as a side-effect of this issue — that's a behavior-neutral change but outside this issue's scope. **`PasswordResetService` already injects `AppUserRepository`** — not a foreign-domain repository. `PasswordResetService` owns password-reset tokens; `AppUser` is in the user domain. The fix (inject `UserService` instead) is correct by the layering rule, but verify `UserService` doesn't already inject `PasswordResetTokenRepository` creating a reverse dependency before you wire it. ### Recommendations - Write the new `DocumentService` read method as a failing test first: `DocumentServiceTest#findById_returnsEmpty_whenDocumentDoesNotExist()`. Then implement. Then update the Thumbnail service tests to mock `DocumentService` instead of `DocumentRepository`. - The `StatsService` implementation is 5 lines: two `count()` calls delegated from repository to service. TDD: write `StatsServiceTest#getStats_returnsCountsFromRepositories()` as the first failing test. - Before touching any production code, run `./mvnw test` and confirm the current suite is green. Record the baseline. Every commit after that must also be green. - The PR description should list the before/after grep counts: "Repository injections in service code: 13 → 0. Repository injections in controller code: 3 → 0." This makes the review trivial.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Observations

This issue is primarily an architectural fix, not a security fix — but two specific items have security implications worth naming explicitly.

StatsController has no @RequirePermission (flagged explicitly in the issue body). This endpoint returns document and person counts. By itself that's low sensitivity, but "how many documents exist" is metadata that could confirm whether a family archive is populated — which has minor information-disclosure weight for a private family system. The fix is simple and the issue already calls for it: add @RequirePermission(Permission.READ_ALL). Do this as part of the StatsController fix commit, not as a separate concern. Don't defer it.

AuthE2EController is currently acceptable as-is from a security standpoint, but the proposed refactor to a @Profile("e2e") service is strictly better. Reasons:

  1. The repository is test infrastructure (reset tokens) — exposing it directly in a controller, even under a profile guard, increases the blast radius if the profile is accidentally enabled in staging.
  2. The @Operation(hidden = true) annotation suppresses the endpoint from OpenAPI docs when running both dev and e2e profiles together. That suppression is correct and must be preserved in the refactored version — add it to the controller method in the new structure too.

No new attack surface is introduced by this refactor. Adding service methods that delegate to repositories doesn't change the HTTP surface or authentication requirements. This is a safe structural change.

Verification grep from the acceptance criteria also covers security. The controller grep confirms no repository can be reached by bypassing the service layer. That's the right control.

Recommendations

  • @RequirePermission(Permission.READ_ALL) goes on StatsController.getStats() in the same commit that introduces StatsService. Don't split this into a separate PR.
  • In the new PasswordResetTestHelper @Profile("e2e") service (or however this is structured), ensure the method that exposes the raw token is not callable from outside the e2e profile. The existing @Profile("e2e") on the controller class provides this, but if you refactor to a helper bean, the @Profile("e2e") annotation must move to the helper class, not be omitted.
  • The StatsControllerTest currently has void getStats_returns401_whenUnauthenticated() — this test must be updated to also verify returns403_when_user_lacks_READ_ALL once the @RequirePermission annotation is added. That's a new test case that should be written first (red) before the annotation is added (green).
## 🔒 Nora "NullX" Steiner — Application Security Engineer ### Observations This issue is primarily an architectural fix, not a security fix — but two specific items have security implications worth naming explicitly. **`StatsController` has no `@RequirePermission`** (flagged explicitly in the issue body). This endpoint returns document and person counts. By itself that's low sensitivity, but "how many documents exist" is metadata that could confirm whether a family archive is populated — which has minor information-disclosure weight for a private family system. The fix is simple and the issue already calls for it: add `@RequirePermission(Permission.READ_ALL)`. **Do this as part of the `StatsController` fix commit, not as a separate concern.** Don't defer it. **`AuthE2EController` is currently acceptable as-is from a security standpoint**, but the proposed refactor to a `@Profile("e2e")` service is strictly better. Reasons: 1. The repository is test infrastructure (reset tokens) — exposing it directly in a controller, even under a profile guard, increases the blast radius if the profile is accidentally enabled in staging. 2. The `@Operation(hidden = true)` annotation suppresses the endpoint from OpenAPI docs when running both `dev` and `e2e` profiles together. That suppression is correct and must be preserved in the refactored version — add it to the controller method in the new structure too. **No new attack surface is introduced by this refactor.** Adding service methods that delegate to repositories doesn't change the HTTP surface or authentication requirements. This is a safe structural change. **Verification grep from the acceptance criteria also covers security.** The controller grep confirms no repository can be reached by bypassing the service layer. That's the right control. ### Recommendations - `@RequirePermission(Permission.READ_ALL)` goes on `StatsController.getStats()` in the same commit that introduces `StatsService`. Don't split this into a separate PR. - In the new `PasswordResetTestHelper @Profile("e2e")` service (or however this is structured), ensure the method that exposes the raw token is not callable from outside the `e2e` profile. The existing `@Profile("e2e")` on the controller class provides this, but if you refactor to a helper bean, the `@Profile("e2e")` annotation must move to the helper class, not be omitted. - The `StatsControllerTest` currently has `void getStats_returns401_whenUnauthenticated()` — this test must be updated to also verify `returns403_when_user_lacks_READ_ALL` once the `@RequirePermission` annotation is added. That's a new test case that should be written first (red) before the annotation is added (green).
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Observations

The test impact is higher than the issue body implies. I found these existing test files that directly reference the repositories being removed:

Test file Mock that breaks
StatsControllerTest @MockitoBean PersonRepository, @MockitoBean DocumentRepository
ThumbnailServiceTest mock(DocumentRepository.class) passed to constructor
MassImportServiceTest @Mock DocumentRepository
ThumbnailBackfillServiceTest Likely @Mock DocumentRepository — verify
ThumbnailAsyncRunnerTest Likely @Mock DocumentRepository
TranscriptionQueueServiceTest @Mock DocumentRepository — confirm
PasswordResetServiceTest @Mock AppUserRepository — confirm

The acceptance criteria don't mention updating these tests, but each one will fail to compile (or produce a mock that's no longer injected) once the corresponding repository injection is removed.

Strategy for each test:
The fix pattern is consistent: replace @Mock DocumentRepository documentRepository with @Mock DocumentService documentService and update the when(...).thenReturn(...) stubs to match the new service method signature. This is purely mechanical but must happen for every affected test in the same commit as the production code change — otherwise the commit-level green signal is meaningless.

Test coverage for new service methods: Each new method added to DocumentService, TranscriptionService, AnnotationService, etc. needs at least:

  • A happy path test
  • An empty/not-found path test (especially for ThumbnailAsyncRunner where the empty case has specific behavior: log + return)

StatsService is new — it needs a StatsServiceTest with @ExtendWith(MockitoExtension.class) before the class exists (TDD red phase).

The acceptance criteria verification greps are excellent and should be run in CI, not just manually. Consider adding them as a Maven Enforcer plugin rule or at minimum as a documented CI check step.

OpenAPI diff verification is also in the acceptance criteria: regenerate the spec and diff. This is a one-command check (./mvnw clean package -DskipTests && java -jar target/*.jar --spring.profiles.active=dev, then hit /v3/api-docs). I'd add this as an explicit step in the PR description checklist.

Recommendations

  • Update every affected test in the same commit as the production change it covers — never let the suite go red between commits.
  • Add void getStats_returns403_when_user_lacks_READ_ALL() to StatsControllerTest before adding @RequirePermission to the controller (per TDD discipline).
  • Write StatsServiceTest before creating StatsService. Two tests needed: counts are delegated correctly, and zero counts return correctly (already implied by the existing controller test shapes).
  • For the Thumbnail cluster: ThumbnailAsyncRunnerTest should have a test covering generateAsync_skips_and_logs_when_document_not_found — this test may already exist but will need to mock DocumentService.findById() returning empty after the refactor.
## 🧪 Sara Holt — QA Engineer & Test Strategist ### Observations The test impact is higher than the issue body implies. I found these existing test files that directly reference the repositories being removed: | Test file | Mock that breaks | |---|---| | `StatsControllerTest` | `@MockitoBean PersonRepository`, `@MockitoBean DocumentRepository` | | `ThumbnailServiceTest` | `mock(DocumentRepository.class)` passed to constructor | | `MassImportServiceTest` | `@Mock DocumentRepository` | | `ThumbnailBackfillServiceTest` | Likely `@Mock DocumentRepository` — verify | | `ThumbnailAsyncRunnerTest` | Likely `@Mock DocumentRepository` | | `TranscriptionQueueServiceTest` | `@Mock DocumentRepository` — confirm | | `PasswordResetServiceTest` | `@Mock AppUserRepository` — confirm | The acceptance criteria don't mention updating these tests, but each one will fail to compile (or produce a mock that's no longer injected) once the corresponding repository injection is removed. **Strategy for each test:** The fix pattern is consistent: replace `@Mock DocumentRepository documentRepository` with `@Mock DocumentService documentService` and update the `when(...).thenReturn(...)` stubs to match the new service method signature. This is purely mechanical but must happen for every affected test in the same commit as the production code change — otherwise the commit-level green signal is meaningless. **Test coverage for new service methods:** Each new method added to `DocumentService`, `TranscriptionService`, `AnnotationService`, etc. needs at least: - A happy path test - An empty/not-found path test (especially for `ThumbnailAsyncRunner` where the empty case has specific behavior: log + return) `StatsService` is new — it needs a `StatsServiceTest` with `@ExtendWith(MockitoExtension.class)` before the class exists (TDD red phase). **The acceptance criteria verification greps are excellent** and should be run in CI, not just manually. Consider adding them as a Maven Enforcer plugin rule or at minimum as a documented CI check step. **OpenAPI diff verification** is also in the acceptance criteria: regenerate the spec and diff. This is a one-command check (`./mvnw clean package -DskipTests && java -jar target/*.jar --spring.profiles.active=dev`, then hit `/v3/api-docs`). I'd add this as an explicit step in the PR description checklist. ### Recommendations - Update every affected test in the same commit as the production change it covers — never let the suite go red between commits. - Add `void getStats_returns403_when_user_lacks_READ_ALL()` to `StatsControllerTest` before adding `@RequirePermission` to the controller (per TDD discipline). - Write `StatsServiceTest` before creating `StatsService`. Two tests needed: counts are delegated correctly, and zero counts return correctly (already implied by the existing controller test shapes). - For the Thumbnail cluster: `ThumbnailAsyncRunnerTest` should have a test covering `generateAsync_skips_and_logs_when_document_not_found` — this test may already exist but will need to mock `DocumentService.findById()` returning empty after the refactor.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Observations

This is a pure backend refactor with no Docker Compose, CI pipeline, or infrastructure impact. No new services, no new environment variables, no migration files. The deployment footprint is unchanged.

Two operational points worth noting:

OpenAPI spec diff is a required check. The acceptance criteria require regenerating the spec and confirming a zero diff. In the current CI setup, the backend tests run without the dev profile, so OpenAPI is disabled during CI. This check must be done manually by the implementer and explicitly documented in the PR. Consider adding a dedicated CI step that starts the backend with --spring.profiles.active=dev, hits /v3/api-docs, and diffs against the committed api-docs.json snapshot — but that's scope for a CI improvement issue, not this one.

./mvnw test is the only CI gate here. The suite currently runs 92 test files. The refactor will compile-fail several of them (see Sara's comment) if the test updates are not included in each commit. The CI run on the PR branch will catch this, but it's cleaner to run ./mvnw test locally after each commit than to wait for CI to report the failure.

No new dependencies are introduced. All the new service methods call existing repository methods — no new Maven dependencies, no new Spring beans beyond possibly StatsService and a PasswordResetTestHelper. Both are trivially small.

Recommendations

  • No infrastructure changes required for this issue.
  • The OpenAPI diff check (acceptance criteria item 5) cannot be automated in the current CI pipeline without backend running with the dev profile. Document this explicitly in the PR as "manually verified: ./mvnw clean package -DskipTests && java -jar ... --spring.profiles.active=dev, confirmed /v3/api-docs diff is empty."
  • After merge, leave a comment on #407 (REFACTOR-1) noting this issue is closed, as the Definition of Done requires. This is a manual step that CI cannot automate.
## 🚀 Tobias Wendt — DevOps & Platform Engineer ### Observations This is a pure backend refactor with no Docker Compose, CI pipeline, or infrastructure impact. No new services, no new environment variables, no migration files. The deployment footprint is unchanged. Two operational points worth noting: **OpenAPI spec diff is a required check.** The acceptance criteria require regenerating the spec and confirming a zero diff. In the current CI setup, the backend tests run without the `dev` profile, so OpenAPI is disabled during CI. This check must be done manually by the implementer and explicitly documented in the PR. Consider adding a dedicated CI step that starts the backend with `--spring.profiles.active=dev`, hits `/v3/api-docs`, and diffs against the committed `api-docs.json` snapshot — but that's scope for a CI improvement issue, not this one. **`./mvnw test` is the only CI gate here.** The suite currently runs 92 test files. The refactor will compile-fail several of them (see Sara's comment) if the test updates are not included in each commit. The CI run on the PR branch will catch this, but it's cleaner to run `./mvnw test` locally after each commit than to wait for CI to report the failure. **No new dependencies are introduced.** All the new service methods call existing repository methods — no new Maven dependencies, no new Spring beans beyond possibly `StatsService` and a `PasswordResetTestHelper`. Both are trivially small. ### Recommendations - No infrastructure changes required for this issue. - The OpenAPI diff check (acceptance criteria item 5) cannot be automated in the current CI pipeline without backend running with the `dev` profile. Document this explicitly in the PR as "manually verified: `./mvnw clean package -DskipTests && java -jar ... --spring.profiles.active=dev`, confirmed `/v3/api-docs` diff is empty." - After merge, leave a comment on #407 (REFACTOR-1) noting this issue is closed, as the Definition of Done requires. This is a manual step that CI cannot automate.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

No UI/UX concerns from my angle.

This issue is entirely a backend layering refactor — no Svelte components, no routes, no frontend changes, and no user-visible behavior changes. The StatsController endpoint (/api/stats) feeds the dashboard counts, but the response shape is unchanged (totalPersons, totalDocuments), so the frontend renders identically before and after.

The one thing I checked: the OpenAPI spec is required to be unchanged (acceptance criteria item 5). If the spec changes, npm run generate:api would regenerate TypeScript types and potentially affect frontend code. The issue correctly requires a zero-diff verification. That's the only path this refactor could reach the UI layer, and it's already covered.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist No UI/UX concerns from my angle. This issue is entirely a backend layering refactor — no Svelte components, no routes, no frontend changes, and no user-visible behavior changes. The `StatsController` endpoint (`/api/stats`) feeds the dashboard counts, but the response shape is unchanged (`totalPersons`, `totalDocuments`), so the frontend renders identically before and after. The one thing I checked: the OpenAPI spec is required to be unchanged (acceptance criteria item 5). If the spec changes, `npm run generate:api` would regenerate TypeScript types and potentially affect frontend code. The issue correctly requires a zero-diff verification. That's the only path this refactor could reach the UI layer, and it's already covered.
Author
Owner

📋 Elicit (Requirements Engineer)

Observations

The issue is exceptionally well-specified. File:line precision, explicit allowlist grep, zero-hit acceptance condition, and a clear DoD including cross-reference comments on #407 and #409. This is implementation-ready as written.

One requirements ambiguity I want to surface before coding starts:

The PasswordResetService situation is slightly different from the other C6.2 violations. The issue categorizes it as "PasswordResetService.java:33 injects AppUserRepository (user domain)." But looking at the code, PasswordResetService also injects PasswordResetTokenRepository — which it legitimately owns. The fix routes AppUserRepository access through UserService.findByEmail(). However: UserService would then need a findByEmail(String email) method that returns an Optional<AppUser> without throwing — because PasswordResetService.requestReset() explicitly handles the "user not found" case silently to prevent user enumeration. Confirm that the new UserService.findByEmail() method returns Optional<AppUser> (not throws) so the caller's silent-fail behavior is preserved.

The TranscriptionService ↔ AnnotationService mutual read dependency (items 10 and 11 in the C6.2 table) could create a circular Spring bean dependency. The issue doesn't flag this as a known risk. It's worth verifying at the start of that fix rather than discovering it mid-commit.

Acceptance criteria completeness check: All five acceptance criteria are measurable and testable. The two greps are exact-match verifiable. The ./mvnw test gate is binary. The OpenAPI diff is verifiable (with the caveat Tobias raised about CI automation). The DoD cross-reference comments are manual but clear. No gaps.

Recommendations

  • Add a note to the implementation plan: verify Optional<AppUser> return type on the new UserService.findByEmail() before the PasswordResetService fix, to preserve user-enumeration prevention behavior.
  • Treat the TranscriptionService ↔ AnnotationService pair as the highest-risk fix — do it last, and start by checking for circular dependency in a spike commit before the full fix.
  • No scope changes needed. The issue is complete as written.
## 📋 Elicit (Requirements Engineer) ### Observations The issue is exceptionally well-specified. File:line precision, explicit allowlist grep, zero-hit acceptance condition, and a clear DoD including cross-reference comments on #407 and #409. This is implementation-ready as written. One requirements ambiguity I want to surface before coding starts: **The `PasswordResetService` situation is slightly different from the other C6.2 violations.** The issue categorizes it as "`PasswordResetService.java:33` injects `AppUserRepository` (user domain)." But looking at the code, `PasswordResetService` also injects `PasswordResetTokenRepository` — which it legitimately owns. The fix routes `AppUserRepository` access through `UserService.findByEmail()`. However: `UserService` would then need a `findByEmail(String email)` method that returns an `Optional<AppUser>` without throwing — because `PasswordResetService.requestReset()` explicitly handles the "user not found" case silently to prevent user enumeration. Confirm that the new `UserService.findByEmail()` method returns `Optional<AppUser>` (not throws) so the caller's silent-fail behavior is preserved. **The `TranscriptionService ↔ AnnotationService` mutual read dependency** (items 10 and 11 in the C6.2 table) could create a circular Spring bean dependency. The issue doesn't flag this as a known risk. It's worth verifying at the start of that fix rather than discovering it mid-commit. **Acceptance criteria completeness check:** All five acceptance criteria are measurable and testable. The two greps are exact-match verifiable. The `./mvnw test` gate is binary. The OpenAPI diff is verifiable (with the caveat Tobias raised about CI automation). The DoD cross-reference comments are manual but clear. No gaps. ### Recommendations - Add a note to the implementation plan: verify `Optional<AppUser>` return type on the new `UserService.findByEmail()` before the `PasswordResetService` fix, to preserve user-enumeration prevention behavior. - Treat the `TranscriptionService ↔ AnnotationService` pair as the highest-risk fix — do it last, and start by checking for circular dependency in a spike commit before the full fix. - No scope changes needed. The issue is complete as written.
Author
Owner

PR #420 opened against main. Eight atomic commits, each green at the test-suite level (1489 → 1503 tests, 0 failures throughout):

Service-side cross-domain repo injections Controller-side repo injections
Before 12 3
After 0 0

Both acceptance-criteria greps return zero hits. ./mvnw test is green; ./mvnw clean package -DskipTests succeeds. No DTO or endpoint signatures touched, so the OpenAPI spec is byte-identical to main (manually verified — automating that diff is its own follow-up per Tobias's comment).

Two design notes worth flagging for the reviewer:

  • Two cycles surfaced while resolving the layering. Both are broken with @Lazy at the injection point, mirroring the existing SenderModelService.self pattern: DocumentService ↔ ThumbnailAsyncRunner and TranscriptionService ↔ AnnotationService. lombok.config was added with lombok.copyableAnnotations += @Lazy so the annotation reaches the Lombok-generated constructor parameter.
  • Block reads from the export + OCR-training cluster route through TranscriptionBlockQueryService rather than TranscriptionService, because SenderModelService → TrainingDataExportService → TranscriptionService → SenderModelService would otherwise re-introduce a cycle. This matches Markus's recommended escape valve.

Closing the loop on #407 with a separate comment now.

PR #420 opened against `main`. Eight atomic commits, each green at the test-suite level (1489 → 1503 tests, 0 failures throughout): | | Service-side cross-domain repo injections | Controller-side repo injections | |---|---|---| | Before | 12 | 3 | | After | **0** | **0** | Both acceptance-criteria greps return zero hits. `./mvnw test` is green; `./mvnw clean package -DskipTests` succeeds. No DTO or endpoint signatures touched, so the OpenAPI spec is byte-identical to `main` (manually verified — automating that diff is its own follow-up per Tobias's comment). Two design notes worth flagging for the reviewer: - **Two cycles surfaced** while resolving the layering. Both are broken with `@Lazy` at the injection point, mirroring the existing `SenderModelService.self` pattern: `DocumentService ↔ ThumbnailAsyncRunner` and `TranscriptionService ↔ AnnotationService`. `lombok.config` was added with `lombok.copyableAnnotations += @Lazy` so the annotation reaches the Lombok-generated constructor parameter. - **Block reads from the export + OCR-training cluster route through `TranscriptionBlockQueryService`** rather than `TranscriptionService`, because `SenderModelService → TrainingDataExportService → TranscriptionService → SenderModelService` would otherwise re-introduce a cycle. This matches Markus's recommended escape valve. Closing the loop on #407 with a separate comment now.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#417