diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 66a56a22..d4715f40 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -1008,9 +1008,20 @@ public class DocumentService { /** * Lightweight summary lookup for internal use (e.g. journey item append validation). - * Intentionally skips scope checks and tag-colour resolution — safe only - * under the current single-tenant model where all authenticated users share - * the same document scope. Called within a caller-provided transaction. + * + *

Security contract — read before calling: + *

    + *
  1. This method intentionally bypasses per-document scope checks and + * tag-colour resolution. It must only be invoked after + * {@code @RequirePermission(BLOG_WRITE)} has already been enforced at + * the controller layer, guaranteeing the caller is an authenticated + * author.
  2. + *
  3. In {@code JourneyItemService.append()}, it is additionally guarded by the + * JOURNEY-type check that fires before this call — so the method is never + * reached for STORY-type Geschichten.
  4. + *
+ * Under the current single-tenant model every authenticated author shares the + * same document scope, so skipping per-document scope checks is safe. */ public Document findSummaryByIdInternal(UUID id) { return documentRepository.findById(id) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java index 91a51c93..c411a3bb 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java @@ -39,6 +39,7 @@ import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -235,6 +236,26 @@ class JourneyItemServiceTest { .isEqualTo(ErrorCode.GESCHICHTE_TYPE_MISMATCH)); } + @Test + void append_never_calls_findSummaryByIdInternal_when_geschichte_type_is_STORY() { + // Arrange: mock geschichteQueryService.findById() to return a STORY-type Geschichte + UUID storyId = UUID.randomUUID(); + Geschichte story = Geschichte.builder() + .id(storyId) + .type(GeschichteType.STORY) + .build(); + when(geschichteQueryService.findById(storyId)).thenReturn(Optional.of(story)); + + // Act + Assert: calling append throws GESCHICHTE_TYPE_MISMATCH + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setDocumentId(UUID.randomUUID()); + assertThatThrownBy(() -> journeyItemService.append(storyId, dto)) + .isInstanceOf(DomainException.class); + + // Verify: document service was never touched — type guard fired first + verifyNoInteractions(documentService); + } + @Test void append_returns404_when_documentId_does_not_exist() { Geschichte journey = journey(geschichteId);