From 08d96e5b0f13a82307d6f55f598d730b378ef2c4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 2 May 2026 17:29:11 +0200 Subject: [PATCH] feat(geschichte): add GeschichteService with HTML sanitization and DRAFT visibility rules DRAFT stories are 404 to readers without BLOG_WRITE (NOT_FOUND, not FORBIDDEN, to avoid leaking existence). list() forces status=PUBLISHED for non-writers even when they pass status=null. Body HTML is sanitised via OWASP allow-list (p, br, strong, em, h2, h3, ul, ol, li) on every save. publishedAt is set on every transition into PUBLISHED and cleared on retract. Co-Authored-By: Claude Opus 4.7 --- .../service/GeschichteService.java | 178 ++++++++ .../service/GeschichteServiceTest.java | 413 ++++++++++++++++++ 2 files changed, 591 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/GeschichteService.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/GeschichteServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/GeschichteService.java new file mode 100644 index 00000000..a0f5d4ba --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/GeschichteService.java @@ -0,0 +1,178 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.owasp.html.HtmlPolicyBuilder; +import org.owasp.html.PolicyFactory; +import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.Geschichte; +import org.raddatz.familienarchiv.model.GeschichteStatus; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.repository.GeschichteRepository; +import org.raddatz.familienarchiv.security.Permission; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GeschichteService { + + private final GeschichteRepository geschichteRepository; + private final PersonService personService; + private final DocumentService documentService; + private final UserService userService; + + /** + * Allow-list policy for Geschichte body HTML. Tiptap on the writer side + * already constrains the marks/nodes, but the backend re-sanitises every + * save so that an attacker calling the API directly cannot inject more. + */ + private static final PolicyFactory BODY_SANITIZER = new HtmlPolicyBuilder() + .allowElements("p", "br", "strong", "em", "h2", "h3", "ul", "ol", "li") + .toFactory(); + + private static final int DEFAULT_LIMIT = 50; + private static final int MAX_LIMIT = 200; + + // ─── Read API ──────────────────────────────────────────────────────────── + + public Geschichte getById(UUID id) { + Geschichte g = geschichteRepository.findById(id) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id)); + if (g.getStatus() == GeschichteStatus.DRAFT && !currentUserHasBlogWrite()) { + // Use NOT_FOUND, not FORBIDDEN — don't leak DRAFT existence. + throw DomainException.notFound( + ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id); + } + return g; + } + + public List list(GeschichteStatus status, UUID personId, UUID documentId, int limit) { + GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED; + int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); + Pageable pageable = PageRequest.of(0, safeLimit); + return geschichteRepository.search(effective, personId, documentId, pageable); + } + + // ─── Write API ─────────────────────────────────────────────────────────── + + @Transactional + public Geschichte create(GeschichteUpdateDTO dto) { + requireTitle(dto.getTitle()); + Geschichte g = Geschichte.builder() + .title(dto.getTitle().trim()) + .body(sanitize(dto.getBody())) + .status(GeschichteStatus.DRAFT) + .author(currentUser()) + .persons(resolvePersons(dto.getPersonIds())) + .documents(resolveDocuments(dto.getDocumentIds())) + .build(); + if (dto.getStatus() == GeschichteStatus.PUBLISHED) { + g.setStatus(GeschichteStatus.PUBLISHED); + g.setPublishedAt(LocalDateTime.now()); + } + return geschichteRepository.save(g); + } + + @Transactional + public Geschichte update(UUID id, GeschichteUpdateDTO dto) { + Geschichte g = geschichteRepository.findById(id) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id)); + if (dto.getTitle() != null) { + requireTitle(dto.getTitle()); + g.setTitle(dto.getTitle().trim()); + } + if (dto.getBody() != null) { + g.setBody(sanitize(dto.getBody())); + } + if (dto.getPersonIds() != null) { + g.setPersons(resolvePersons(dto.getPersonIds())); + } + if (dto.getDocumentIds() != null) { + g.setDocuments(resolveDocuments(dto.getDocumentIds())); + } + if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) { + applyStatusTransition(g, dto.getStatus()); + } + return geschichteRepository.save(g); + } + + @Transactional + public void delete(UUID id) { + if (!geschichteRepository.existsById(id)) { + throw DomainException.notFound( + ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id); + } + geschichteRepository.deleteById(id); + } + + // ─── private helpers ───────────────────────────────────────────────────── + + private void applyStatusTransition(Geschichte g, GeschichteStatus next) { + g.setStatus(next); + if (next == GeschichteStatus.PUBLISHED) { + g.setPublishedAt(LocalDateTime.now()); + } else { + g.setPublishedAt(null); + } + } + + private void requireTitle(String title) { + if (title == null || title.trim().isEmpty()) { + throw DomainException.badRequest( + ErrorCode.VALIDATION_ERROR, "Title is required"); + } + } + + private String sanitize(String body) { + if (body == null) return null; + return BODY_SANITIZER.sanitize(body); + } + + private Set resolvePersons(List ids) { + if (ids == null || ids.isEmpty()) return new HashSet<>(); + return new LinkedHashSet<>(personService.getAllById(ids)); + } + + private Set resolveDocuments(List ids) { + if (ids == null || ids.isEmpty()) return new HashSet<>(); + Set out = new LinkedHashSet<>(); + for (UUID id : ids) { + out.add(documentService.getDocumentById(id)); + } + return out; + } + + private AppUser currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + throw DomainException.unauthorized("Authentication required"); + } + return userService.findByEmail(auth.getName()); + } + + private boolean currentUserHasBlogWrite() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) return false; + return auth.getAuthorities().stream() + .anyMatch(a -> Permission.BLOG_WRITE.name().equals(a.getAuthority())); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/GeschichteServiceTest.java new file mode 100644 index 00000000..ffd6b15f --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/GeschichteServiceTest.java @@ -0,0 +1,413 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.dto.GeschichteUpdateDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.Geschichte; +import org.raddatz.familienarchiv.model.GeschichteStatus; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.repository.GeschichteRepository; +import org.raddatz.familienarchiv.security.Permission; +import org.springframework.data.domain.Pageable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GeschichteServiceTest { + + @Mock + GeschichteRepository geschichteRepository; + @Mock + PersonService personService; + @Mock + DocumentService documentService; + @Mock + UserService userService; + + @InjectMocks + GeschichteService geschichteService; + + AppUser writer; + AppUser reader; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + writer = AppUser.builder().id(UUID.randomUUID()).email("writer@test").build(); + reader = AppUser.builder().id(UUID.randomUUID()).email("reader@test").build(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // ─── getById ────────────────────────────────────────────────────────────── + + @Test + void getById_throws_NOT_FOUND_for_draft_when_user_lacks_BLOG_WRITE() { + authenticateAs(reader, Permission.READ_ALL); + UUID id = UUID.randomUUID(); + Geschichte draft = draft(id); + when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft)); + + assertThatThrownBy(() -> geschichteService.getById(id)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND); + } + + @Test + void getById_returns_draft_when_user_has_BLOG_WRITE() { + authenticateAs(writer, Permission.BLOG_WRITE); + UUID id = UUID.randomUUID(); + Geschichte draft = draft(id); + when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft)); + + Geschichte result = geschichteService.getById(id); + + assertThat(result).isSameAs(draft); + } + + @Test + void getById_returns_published_to_anyone_authenticated() { + authenticateAs(reader, Permission.READ_ALL); + UUID id = UUID.randomUUID(); + Geschichte published = published(id); + when(geschichteRepository.findById(id)).thenReturn(Optional.of(published)); + + Geschichte result = geschichteService.getById(id); + + assertThat(result).isSameAs(published); + } + + @Test + void getById_throws_NOT_FOUND_when_id_unknown() { + authenticateAs(reader, Permission.READ_ALL); + UUID id = UUID.randomUUID(); + when(geschichteRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> geschichteService.getById(id)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND); + } + + // ─── list ───────────────────────────────────────────────────────────────── + + @Test + void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() { + authenticateAs(reader, Permission.READ_ALL); + when(geschichteRepository.search(eq(GeschichteStatus.PUBLISHED), any(), any(), any())) + .thenReturn(List.of(published(UUID.randomUUID()))); + + geschichteService.list(/*status*/ null, /*personId*/ null, /*documentId*/ null, /*limit*/ 50); + + verify(geschichteRepository).search(eq(GeschichteStatus.PUBLISHED), any(), any(), any()); + } + + @Test + void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() { + authenticateAs(writer, Permission.BLOG_WRITE); + when(geschichteRepository.search(any(), any(), any(), any())) + .thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID()))); + + geschichteService.list(null, null, null, 50); + + verify(geschichteRepository).search(eq(null), any(), any(), any()); + } + + @Test + void list_filters_by_personId() { + authenticateAs(reader, Permission.READ_ALL); + UUID personId = UUID.randomUUID(); + when(geschichteRepository.search(any(), eq(personId), any(), any())) + .thenReturn(List.of()); + + geschichteService.list(null, personId, null, 50); + + verify(geschichteRepository).search(eq(GeschichteStatus.PUBLISHED), eq(personId), eq(null), any()); + } + + @Test + void list_filters_by_documentId() { + authenticateAs(reader, Permission.READ_ALL); + UUID documentId = UUID.randomUUID(); + when(geschichteRepository.search(any(), any(), eq(documentId), any())) + .thenReturn(List.of()); + + geschichteService.list(null, null, documentId, 50); + + verify(geschichteRepository).search(eq(GeschichteStatus.PUBLISHED), eq(null), eq(documentId), any()); + } + + // ─── create ────────────────────────────────────────────────────────────── + + @Test + void create_sets_status_to_DRAFT_by_default() { + authenticateAs(writer, Permission.BLOG_WRITE); + when(userService.findByEmail(writer.getEmail())).thenReturn(writer); + when(geschichteRepository.save(any(Geschichte.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setTitle("My Story"); + dto.setBody("

plain text

"); + + Geschichte saved = geschichteService.create(dto); + + assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT); + assertThat(saved.getPublishedAt()).isNull(); + assertThat(saved.getAuthor()).isSameAs(writer); + } + + @Test + void create_sanitizes_body_HTML_dropping_disallowed_tags() { + authenticateAs(writer, Permission.BLOG_WRITE); + when(userService.findByEmail(writer.getEmail())).thenReturn(writer); + when(geschichteRepository.save(any(Geschichte.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); + dto.setTitle("XSS attempt"); + dto.setBody("

safe

"); + + Geschichte saved = geschichteService.create(dto); + + assertThat(saved.getBody()) + .contains("

safe

") + .doesNotContain(""); + + Geschichte saved = geschichteService.update(id, dto); + + assertThat(saved.getBody()).doesNotContain("