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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-02 17:29:11 +02:00
parent b7a2f6c2fe
commit 08d96e5b0f
2 changed files with 591 additions and 0 deletions

View File

@@ -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<Geschichte> 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<Person> resolvePersons(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return new HashSet<>();
return new LinkedHashSet<>(personService.getAllById(ids));
}
private Set<Document> resolveDocuments(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return new HashSet<>();
Set<Document> 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()));
}
}

View File

@@ -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("<p>plain text</p>");
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("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
Geschichte saved = geschichteService.create(dto);
assertThat(saved.getBody())
.contains("<p>safe</p>")
.doesNotContain("<script>")
.doesNotContain("onerror")
.doesNotContain("<img");
}
@Test
void create_keeps_allowed_tags_strong_em_h2_h3_ul_ol_li() {
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("Rich");
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
Geschichte saved = geschichteService.create(dto);
assertThat(saved.getBody())
.contains("<h2>Heading</h2>")
.contains("<strong>bold</strong>")
.contains("<em>italic</em>")
.contains("<ul>")
.contains("<ol>")
.contains("<li>one</li>");
}
@Test
void create_resolves_personIds_via_PersonService() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
UUID personId = UUID.randomUUID();
Person person = Person.builder().id(personId).build();
when(personService.getAllById(List.of(personId))).thenReturn(List.of(person));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Linked");
dto.setPersonIds(List.of(personId));
Geschichte saved = geschichteService.create(dto);
assertThat(saved.getPersons()).containsExactly(person);
}
@Test
void create_resolves_documentIds_via_DocumentService() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).build();
when(documentService.getDocumentById(docId)).thenReturn(doc);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Linked doc");
dto.setDocumentIds(List.of(docId));
Geschichte saved = geschichteService.create(dto);
assertThat(saved.getDocuments()).containsExactly(doc);
}
@Test
void create_throws_BAD_REQUEST_when_title_blank() {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle(" ");
dto.setBody("<p>x</p>");
assertThatThrownBy(() -> geschichteService.create(dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
}
// ─── update ──────────────────────────────────────────────────────────────
@Test
void update_sets_publishedAt_when_status_transitions_to_PUBLISHED() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setPublishedAt(null);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte saved = geschichteService.update(id, dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
assertThat(saved.getPublishedAt()).isNotNull();
}
@Test
void update_clears_publishedAt_when_status_transitions_back_to_DRAFT() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = published(id);
existing.setPublishedAt(LocalDateTime.now().minusDays(1));
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.DRAFT);
Geschichte saved = geschichteService.update(id, dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull();
}
@Test
void update_sanitizes_body_on_save() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>ok</p><script>alert(1)</script>");
Geschichte saved = geschichteService.update(id, dto);
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
}
@Test
void update_throws_NOT_FOUND_when_geschichte_unknown() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> geschichteService.update(id, new GeschichteUpdateDTO()))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
}
// ─── delete ──────────────────────────────────────────────────────────────
@Test
void delete_calls_repository_deleteById() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
when(geschichteRepository.existsById(id)).thenReturn(true);
geschichteService.delete(id);
verify(geschichteRepository).deleteById(id);
}
@Test
void delete_throws_NOT_FOUND_when_unknown() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
when(geschichteRepository.existsById(id)).thenReturn(false);
assertThatThrownBy(() -> geschichteService.delete(id))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
verify(geschichteRepository, never()).deleteById(any());
}
// ─── helpers ─────────────────────────────────────────────────────────────
private void authenticateAs(AppUser user, Permission... permissions) {
var authorities = List.of(permissions).stream()
.map(p -> new SimpleGrantedAuthority(p.name()))
.collect(Collectors.toList());
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
}
private Geschichte draft(UUID id) {
return Geschichte.builder()
.id(id)
.title("Draft")
.body("<p>body</p>")
.status(GeschichteStatus.DRAFT)
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
}
private Geschichte published(UUID id) {
return Geschichte.builder()
.id(id)
.title("Published")
.body("<p>body</p>")
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now().minusHours(1))
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
}
}