From a52b0a9d246c16778a885db6e39b7118e4f329c8 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Wed, 8 Apr 2026 22:34:28 +0200 Subject: [PATCH] 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 --- .../planning/WeekPlanController.java | 4 ++ .../planning/WeekPlanControllerTest.java | 64 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java b/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java index 0306fb0..30775fd 100644 --- a/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java +++ b/backend/src/main/java/com/recipeapp/planning/WeekPlanController.java @@ -1,5 +1,6 @@ package com.recipeapp.planning; +import com.recipeapp.common.RequiresHouseholdRole; import com.recipeapp.planning.dto.*; import com.recipeapp.recipe.HouseholdResolver; import jakarta.validation.Valid; @@ -40,6 +41,7 @@ public class WeekPlanController { } @PostMapping("/{id}/slots") + @RequiresHouseholdRole("planner") public ResponseEntity addSlot( Principal principal, @PathVariable UUID id, @@ -50,6 +52,7 @@ public class WeekPlanController { } @PatchMapping("/{planId}/slots/{slotId}") + @RequiresHouseholdRole("planner") public SlotResponse updateSlot( Principal principal, @PathVariable UUID planId, @@ -61,6 +64,7 @@ public class WeekPlanController { @DeleteMapping("/{planId}/slots/{slotId}") @ResponseStatus(HttpStatus.NO_CONTENT) + @RequiresHouseholdRole("planner") public void deleteSlot( Principal principal, @PathVariable UUID planId, diff --git a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java index 888e824..ce24ed6 100644 --- a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java +++ b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java @@ -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()); + } }