feat(planning): enforce planner role on slot mutation endpoints

PATCH, DELETE, and POST slot endpoints now return 403 Forbidden
when called by a household member.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 22:34:28 +02:00
parent f6265efa92
commit a52b0a9d24
2 changed files with 68 additions and 0 deletions

View File

@@ -3,9 +3,11 @@ package com.recipeapp.planning;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.recipeapp.common.GlobalExceptionHandler;
import com.recipeapp.common.HouseholdRoleInterceptor;
import com.recipeapp.common.ValidationException;
import com.recipeapp.planning.dto.*;
import com.recipeapp.recipe.HouseholdResolver;
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;
@@ -13,6 +15,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@@ -49,6 +53,11 @@ class WeekPlanControllerTest {
.build();
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void getWeekPlanShouldReturn200() throws Exception {
var plan = new WeekPlanResponse(PLAN_ID, WEEK_START, "draft", null, List.of());
@@ -182,4 +191,59 @@ class WeekPlanControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.score").value(7.5));
}
@Test
void addSlotShouldReturn403ForMemberRole() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("member@example.com", null));
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
.setControllerAdvice(new GlobalExceptionHandler())
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
.build();
var recipeId = UUID.randomUUID();
mockMvcWithInterceptor.perform(post("/v1/week-plans/{id}/slots", PLAN_ID)
.principal(() -> "member@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new CreateSlotRequest(WEEK_START.plusDays(1), recipeId))))
.andExpect(status().isForbidden());
}
@Test
void updateSlotShouldReturn403ForMemberRole() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("member@example.com", null));
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
.setControllerAdvice(new GlobalExceptionHandler())
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
.build();
var recipeId = UUID.randomUUID();
mockMvcWithInterceptor.perform(patch("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID)
.principal(() -> "member@example.com")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new UpdateSlotRequest(recipeId))))
.andExpect(status().isForbidden());
}
@Test
void deleteSlotShouldReturn403ForMemberRole() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("member@example.com", null));
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
MockMvc mockMvcWithInterceptor = MockMvcBuilders.standaloneSetup(weekPlanController)
.setControllerAdvice(new GlobalExceptionHandler())
.addInterceptors(new HouseholdRoleInterceptor(householdResolver))
.build();
mockMvcWithInterceptor.perform(delete("/v1/week-plans/{planId}/slots/{slotId}", PLAN_ID, SLOT_ID)
.principal(() -> "member@example.com"))
.andExpect(status().isForbidden());
}
}