diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventController.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventController.java new file mode 100644 index 00000000..e3c62e74 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineEventController.java @@ -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 delete(@PathVariable UUID id) { + timelineEventService.delete(id); + return ResponseEntity.noContent().build(); + } + + private UUID requireUserId(Authentication authentication) { + return SecurityUtils.requireUserId(authentication, userService); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventControllerTest.java new file mode 100644 index 00000000..3c6442db --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventControllerTest.java @@ -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)); + } +}