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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user