feat(timeline): add TimelineEventController CRUD endpoints

POST→201, PUT→200, DELETE→204, GET→200; @RequirePermission(WRITE_ALL) on the
three writes, GET via global auth baseline (no annotation, documented). @Valid
request body; all bodies are TimelineEventView. Injects UserService + private
requireUserId wrapper. Controller slice tests cover 401/403/exact-status per
verb, GET 404, service PERSON_NOT_FOUND→404, Bean-Validation 400s carrying
code=VALIDATION_ERROR, and ArgumentCaptor proof that actorId is the resolved
session principal (not a forged body field) on both write paths.

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-13 10:55:22 +02:00
committed by marcel
parent c51fc5e79f
commit 390ab30260
2 changed files with 344 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.security.SecurityUtils;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/api/timeline/events")
@RequiredArgsConstructor
public class TimelineEventController {
private final TimelineEventService timelineEventService;
private final UserService userService;
/**
* No {@code @RequirePermission} on GET by design: the global {@code anyRequest().authenticated()}
* rule is the READ_ALL baseline, consistent with {@code DocumentController.getDocument}. Do not
* "fix" the missing annotation.
*/
@GetMapping("/{id}")
public TimelineEventView getEvent(@PathVariable UUID id) {
return timelineEventService.getEvent(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(Permission.WRITE_ALL)
public TimelineEventView create(@Valid @RequestBody TimelineEventRequest request, Authentication authentication) {
return timelineEventService.create(request, requireUserId(authentication));
}
@PutMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public TimelineEventView update(
@PathVariable UUID id,
@Valid @RequestBody TimelineEventRequest request,
Authentication authentication) {
return timelineEventService.update(id, request, requireUserId(authentication));
}
@DeleteMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Void> delete(@PathVariable UUID id) {
timelineEventService.delete(id);
return ResponseEntity.noContent().build();
}
private UUID requireUserId(Authentication authentication) {
return SecurityUtils.requireUserId(authentication, userService);
}
}

View File

@@ -0,0 +1,273 @@
package org.raddatz.familienarchiv.timeline;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(TimelineEventController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class TimelineEventControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean TimelineEventService timelineEventService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final String VALID_JSON =
"{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\"}";
/** Default principal resolution for @WithMockUser's "user"; capture tests override with a known id. */
@BeforeEach
void resolveDefaultPrincipal() {
when(userService.findByEmail("user"))
.thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build());
}
private TimelineEventView sampleView() {
return new TimelineEventView(
UUID.randomUUID(), "Hochzeit", EventType.PERSONAL, LocalDate.of(1914, 1, 1),
org.raddatz.familienarchiv.document.DatePrecision.YEAR, null, null, 0L,
UUID.randomUUID(), LocalDateTime.now(), UUID.randomUUID(), LocalDateTime.now(),
List.of(), List.of());
}
// ─── POST /api/timeline/events ───────────────────────────────────────────
@Test
void create_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void create_returns403_whenOnlyReadAll() throws Exception {
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns201_andViewBody_whenWriteAll() throws Exception {
when(timelineEventService.create(any(), any())).thenReturn(sampleView());
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("Hochzeit"))
.andExpect(jsonPath("$.version").value(0));
}
// ─── PUT /api/timeline/events/{id} ───────────────────────────────────────
@Test
void update_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void update_returns403_whenOnlyReadAll() throws Exception {
mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void update_returns200_andViewBody_whenWriteAll() throws Exception {
when(timelineEventService.update(any(), any(), any())).thenReturn(sampleView());
mockMvc.perform(put("/api/timeline/events/" + UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(VALID_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Hochzeit"));
}
// ─── DELETE /api/timeline/events/{id} ────────────────────────────────────
@Test
void delete_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void delete_returns403_whenOnlyReadAll() throws Exception {
mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void delete_returns204_whenWriteAll() throws Exception {
mockMvc.perform(delete("/api/timeline/events/" + UUID.randomUUID()).with(csrf()))
.andExpect(status().isNoContent());
}
// ─── GET /api/timeline/events/{id} ───────────────────────────────────────
@Test
void getEvent_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getEvent_returns200_whenAuthenticated() throws Exception {
when(timelineEventService.getEvent(any())).thenReturn(sampleView());
mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Hochzeit"));
}
@Test
@WithMockUser
void getEvent_returns404_whenMissing() throws Exception {
when(timelineEventService.getEvent(any()))
.thenThrow(DomainException.notFound(ErrorCode.TIMELINE_EVENT_NOT_FOUND, "not found"));
mockMvc.perform(get("/api/timeline/events/" + UUID.randomUUID()))
.andExpect(status().isNotFound());
}
// ─── service-thrown link errors map to status ────────────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns404_whenServiceThrowsPersonNotFound() throws Exception {
when(timelineEventService.create(any(), any()))
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "One or more person IDs not found"));
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"personIds\":[\""
+ UUID.randomUUID() + "\"]}"))
.andExpect(status().isNotFound());
}
// ─── Bean Validation 400s carry code VALIDATION_ERROR (R1) ───────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns400_VALIDATION_ERROR_whenTitleBlank() throws Exception {
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\" \",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns400_VALIDATION_ERROR_whenTypeNull() throws Exception {
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"eventDate\":\"1914-07-28\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns400_VALIDATION_ERROR_whenDescriptionTooLong() throws Exception {
String description = "x".repeat(5001);
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"description\":\""
+ description + "\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void create_returns400_VALIDATION_ERROR_whenTooManyPersonIds() throws Exception {
String ids = Stream.generate(() -> "\"" + UUID.randomUUID() + "\"").limit(51).collect(Collectors.joining(","));
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"personIds\":["
+ ids + "]}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
// ─── actorId is the resolved session principal, not a body field (both write paths) ──
@Test
@WithMockUser(username = "curator@example.com", authorities = "WRITE_ALL")
void create_passesResolvedPrincipalIdAsActor_ignoringBodyCreatedBy() throws Exception {
UUID principalId = UUID.randomUUID();
when(userService.findByEmail("curator@example.com"))
.thenReturn(AppUser.builder().id(principalId).email("curator@example.com").build());
when(timelineEventService.create(any(), any())).thenReturn(sampleView());
// body carries a forged createdBy — it must be ignored, actor comes from the principal
mockMvc.perform(post("/api/timeline/events").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"createdBy\":\""
+ UUID.randomUUID() + "\"}"))
.andExpect(status().isCreated());
verify(timelineEventService).create(any(), eq(principalId));
}
@Test
@WithMockUser(username = "curator@example.com", authorities = "WRITE_ALL")
void update_passesResolvedPrincipalIdAsActor_ignoringBodyUpdatedBy() throws Exception {
UUID principalId = UUID.randomUUID();
UUID eventId = UUID.randomUUID();
when(userService.findByEmail("curator@example.com"))
.thenReturn(AppUser.builder().id(principalId).email("curator@example.com").build());
when(timelineEventService.update(any(), any(), any())).thenReturn(sampleView());
mockMvc.perform(put("/api/timeline/events/" + eventId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Hochzeit\",\"type\":\"PERSONAL\",\"eventDate\":\"1914-07-28\",\"updatedBy\":\""
+ UUID.randomUUID() + "\"}"))
.andExpect(status().isOk());
verify(timelineEventService).update(eq(eventId), any(), eq(principalId));
}
}