feat(auth): add @RequiresHouseholdRole annotation with interceptor

Reusable annotation for planner-only endpoints. Uses a
HandlerInterceptor that resolves the household role from the
authenticated user and throws 403 if the role doesn't match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 18:22:47 +02:00
parent 2f690eb3cb
commit 3be9f502c6
5 changed files with 163 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
package com.recipeapp.common;
import com.recipeapp.recipe.HouseholdResolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.method.HandlerMethod;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class HouseholdRoleInterceptorTest {
@Mock private HouseholdResolver householdResolver;
@Mock private HttpServletRequest request;
@Mock private HttpServletResponse response;
@InjectMocks private HouseholdRoleInterceptor interceptor;
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
}
private void authenticateAs(String email) {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(email, null));
}
@Test
void shouldAllowWhenUserHasRequiredRole() throws Exception {
authenticateAs("planner@example.com");
when(householdResolver.resolveRole("planner@example.com")).thenReturn("planner");
var handlerMethod = mock(HandlerMethod.class);
var annotation = mock(RequiresHouseholdRole.class);
when(annotation.value()).thenReturn("planner");
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation);
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertThat(result).isTrue();
}
@Test
void shouldThrowForbiddenWhenUserLacksRequiredRole() {
authenticateAs("member@example.com");
when(householdResolver.resolveRole("member@example.com")).thenReturn("member");
var handlerMethod = mock(HandlerMethod.class);
var annotation = mock(RequiresHouseholdRole.class);
when(annotation.value()).thenReturn("planner");
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation);
assertThatThrownBy(() -> interceptor.preHandle(request, response, handlerMethod))
.isInstanceOf(ForbiddenException.class)
.hasMessageContaining("planner");
}
@Test
void shouldPassThroughWhenNoAnnotation() throws Exception {
var handlerMethod = mock(HandlerMethod.class);
when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(null);
boolean result = interceptor.preHandle(request, response, handlerMethod);
assertThat(result).isTrue();
}
@Test
void shouldPassThroughWhenNotHandlerMethod() throws Exception {
boolean result = interceptor.preHandle(request, response, new Object());
assertThat(result).isTrue();
}
}