diff --git a/src/test/java/io/spring/api/exception/CustomizeExceptionHandlerTest.java b/src/test/java/io/spring/api/exception/CustomizeExceptionHandlerTest.java new file mode 100644 index 000000000..7b6c8bd8d --- /dev/null +++ b/src/test/java/io/spring/api/exception/CustomizeExceptionHandlerTest.java @@ -0,0 +1,130 @@ +package io.spring.api.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import javax.validation.Path; +import javax.validation.metadata.ConstraintDescriptor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.context.request.WebRequest; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.annotation.Annotation; + +@ExtendWith(MockitoExtension.class) +class CustomizeExceptionHandlerTest { + + private CustomizeExceptionHandler handler; + private WebRequest webRequest; + + @BeforeEach + void setUp() { + handler = new CustomizeExceptionHandler(); + webRequest = mock(WebRequest.class); + } + + @Test + void handleInvalidRequest_with_field_errors() { + BeanPropertyBindingResult errors = new BeanPropertyBindingResult(new Object(), "article"); + errors.addError(new FieldError("article", "title", "can't be empty")); + errors.addError(new FieldError("article", "body", "can't be empty")); + + InvalidRequestException ex = new InvalidRequestException(errors); + + ResponseEntity response = handler.handleInvalidRequest(ex, webRequest); + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, response.getStatusCode()); + assertNotNull(response.getBody()); + ErrorResource errorResource = (ErrorResource) response.getBody(); + assertEquals(2, errorResource.getFieldErrors().size()); + } + + @Test + @SuppressWarnings("unchecked") + void handleInvalidAuthentication_returns_422_with_message() { + InvalidAuthenticationException ex = new InvalidAuthenticationException(); + + ResponseEntity response = handler.handleInvalidAuthentication(ex, webRequest); + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, response.getStatusCode()); + Map body = (Map) response.getBody(); + assertNotNull(body); + assertEquals("invalid email or password", body.get("message")); + } + + @Test + void handleMethodArgumentNotValid_returns_422() throws Exception { + BeanPropertyBindingResult bindingResult = + new BeanPropertyBindingResult(new Object(), "user"); + bindingResult.addError(new FieldError("user", "email", "must not be blank")); + + MethodArgumentNotValidException ex = + new MethodArgumentNotValidException(null, bindingResult); + + ResponseEntity response = + handler.handleMethodArgumentNotValid(ex, null, HttpStatus.BAD_REQUEST, webRequest); + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, response.getStatusCode()); + assertNotNull(response.getBody()); + ErrorResource errorResource = (ErrorResource) response.getBody(); + assertEquals(1, errorResource.getFieldErrors().size()); + assertEquals("email", errorResource.getFieldErrors().get(0).getField()); + } + + @Test + @SuppressWarnings("unchecked") + void handleConstraintViolation_with_multiple_violations() { + Set> violations = new HashSet<>(); + + ConstraintViolation violation1 = mock(ConstraintViolation.class); + Path path1 = mock(Path.class); + when(path1.toString()).thenReturn("method.arg.title"); + when(violation1.getPropertyPath()).thenReturn(path1); + when(violation1.getMessage()).thenReturn("must not be blank"); + when(violation1.getRootBeanClass()).thenReturn((Class) Object.class); + ConstraintDescriptor descriptor1 = mock(ConstraintDescriptor.class); + Annotation annotation1 = mock(Annotation.class); + when(annotation1.annotationType()).thenReturn((Class) Override.class); + when(descriptor1.getAnnotation()).thenReturn(annotation1); + doReturn(descriptor1).when(violation1).getConstraintDescriptor(); + + ConstraintViolation violation2 = mock(ConstraintViolation.class); + Path path2 = mock(Path.class); + when(path2.toString()).thenReturn("method.arg.body"); + when(violation2.getPropertyPath()).thenReturn(path2); + when(violation2.getMessage()).thenReturn("must not be empty"); + when(violation2.getRootBeanClass()).thenReturn((Class) Object.class); + ConstraintDescriptor descriptor2 = mock(ConstraintDescriptor.class); + Annotation annotation2 = mock(Annotation.class); + when(annotation2.annotationType()).thenReturn((Class) Override.class); + when(descriptor2.getAnnotation()).thenReturn(annotation2); + doReturn(descriptor2).when(violation2).getConstraintDescriptor(); + + violations.add(violation1); + violations.add(violation2); + + ConstraintViolationException ex = new ConstraintViolationException(violations); + + ErrorResource result = handler.handleConstraintViolation(ex, webRequest); + + assertNotNull(result); + assertEquals(2, result.getFieldErrors().size()); + } +} diff --git a/src/test/java/io/spring/api/exception/ErrorResourceSerializerTest.java b/src/test/java/io/spring/api/exception/ErrorResourceSerializerTest.java new file mode 100644 index 000000000..737ead7ab --- /dev/null +++ b/src/test/java/io/spring/api/exception/ErrorResourceSerializerTest.java @@ -0,0 +1,80 @@ +package io.spring.api.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.StringWriter; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ErrorResourceSerializerTest { + + private ErrorResourceSerializer serializer; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + serializer = new ErrorResourceSerializer(); + objectMapper = new ObjectMapper(); + } + + @Test + void serialize_single_field_error() throws Exception { + FieldErrorResource fieldError = + new FieldErrorResource("article", "title", "NotBlank", "can't be empty"); + ErrorResource errorResource = new ErrorResource(Collections.singletonList(fieldError)); + + String json = serializeToJson(errorResource); + + assertEquals("{\"errors\":{\"title\":[\"can't be empty\"]}}", json); + } + + @Test + void serialize_multiple_errors_on_same_field_should_be_grouped() throws Exception { + List fieldErrors = + Arrays.asList( + new FieldErrorResource("article", "title", "NotBlank", "can't be empty"), + new FieldErrorResource("article", "title", "Size", "too short")); + ErrorResource errorResource = new ErrorResource(fieldErrors); + + String json = serializeToJson(errorResource); + + assertTrue(json.contains("\"errors\"")); + assertTrue(json.contains("\"title\"")); + assertTrue(json.contains("can't be empty")); + assertTrue(json.contains("too short")); + int titleCount = json.split("\"title\"").length - 1; + assertEquals(1, titleCount); + } + + @Test + void serialize_errors_on_different_fields() throws Exception { + List fieldErrors = + Arrays.asList( + new FieldErrorResource("article", "title", "NotBlank", "can't be empty"), + new FieldErrorResource("article", "body", "NotBlank", "can't be empty")); + ErrorResource errorResource = new ErrorResource(fieldErrors); + + String json = serializeToJson(errorResource); + + assertTrue(json.contains("\"title\"")); + assertTrue(json.contains("\"body\"")); + assertTrue(json.contains("\"errors\"")); + } + + private String serializeToJson(ErrorResource errorResource) throws Exception { + StringWriter writer = new StringWriter(); + JsonGenerator gen = new JsonFactory().createGenerator(writer); + SerializerProvider provider = objectMapper.getSerializerProvider(); + serializer.serialize(errorResource, gen, provider); + gen.flush(); + return writer.toString(); + } +} diff --git a/src/test/java/io/spring/api/security/JwtTokenFilterTest.java b/src/test/java/io/spring/api/security/JwtTokenFilterTest.java new file mode 100644 index 000000000..dd5aa569e --- /dev/null +++ b/src/test/java/io/spring/api/security/JwtTokenFilterTest.java @@ -0,0 +1,111 @@ +package io.spring.api.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.spring.core.service.JwtService; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import java.util.Optional; +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +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; + +@ExtendWith(MockitoExtension.class) +class JwtTokenFilterTest { + + @Mock private JwtService jwtService; + @Mock private UserRepository userRepository; + @Mock private HttpServletRequest request; + @Mock private HttpServletResponse response; + @Mock private FilterChain filterChain; + + @InjectMocks private JwtTokenFilter jwtTokenFilter; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Test + void should_continue_filter_chain_when_no_authorization_header() throws Exception { + when(request.getHeader("Authorization")).thenReturn(null); + + jwtTokenFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void should_continue_filter_chain_when_malformed_header_no_space() throws Exception { + when(request.getHeader("Authorization")).thenReturn("TokenWithNoSpace"); + + jwtTokenFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void should_set_authentication_when_valid_token_and_user_exists() throws Exception { + String token = "valid-token"; + User user = new User("test@test.com", "testuser", "pass", "", ""); + + when(request.getHeader("Authorization")).thenReturn("Token " + token); + when(jwtService.getSubFromToken(eq(token))).thenReturn(Optional.of(user.getId())); + when(userRepository.findById(eq(user.getId()))).thenReturn(Optional.of(user)); + + jwtTokenFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + assertEquals(user, SecurityContextHolder.getContext().getAuthentication().getPrincipal()); + } + + @Test + void should_not_set_authentication_when_valid_token_but_user_not_in_db() throws Exception { + String token = "valid-token"; + String userId = "non-existent-user-id"; + + when(request.getHeader("Authorization")).thenReturn("Token " + token); + when(jwtService.getSubFromToken(eq(token))).thenReturn(Optional.of(userId)); + when(userRepository.findById(eq(userId))).thenReturn(Optional.empty()); + + jwtTokenFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void should_not_overwrite_existing_authentication() throws Exception { + String token = "valid-token"; + User user = new User("test@test.com", "testuser", "pass", "", ""); + + UsernamePasswordAuthenticationToken existingAuth = + new UsernamePasswordAuthenticationToken("existing-principal", null); + SecurityContextHolder.getContext().setAuthentication(existingAuth); + + when(request.getHeader("Authorization")).thenReturn("Token " + token); + when(jwtService.getSubFromToken(eq(token))).thenReturn(Optional.of(user.getId())); + + jwtTokenFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + assertSame(existingAuth, SecurityContextHolder.getContext().getAuthentication()); + } +} diff --git a/src/test/java/io/spring/api/security/WebSecurityConfigTest.java b/src/test/java/io/spring/api/security/WebSecurityConfigTest.java new file mode 100644 index 000000000..f491d64b0 --- /dev/null +++ b/src/test/java/io/spring/api/security/WebSecurityConfigTest.java @@ -0,0 +1,94 @@ +package io.spring.api.security; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +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.options; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import io.spring.core.service.JwtService; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +class WebSecurityConfigTest { + + @Autowired private MockMvc mvc; + + @MockBean private JwtService jwtService; + @MockBean private UserRepository userRepository; + + @Test + void options_requests_are_permitted() throws Exception { + mvc.perform(options("/articles")) + .andExpect(status().is(org.hamcrest.Matchers.not(401))); + } + + @Test + void graphql_endpoint_is_public() throws Exception { + mvc.perform(post("/graphql").content("{}").contentType("application/json")) + .andExpect(status().is(org.hamcrest.Matchers.not(401))); + } + + @Test + void graphiql_endpoint_is_public() throws Exception { + mvc.perform(get("/graphiql")) + .andExpect(status().is(org.hamcrest.Matchers.not(401))); + } + + @Test + void get_articles_is_public() throws Exception { + mvc.perform(get("/articles")) + .andExpect(status().is(org.hamcrest.Matchers.not(401))); + } + + @Test + void get_articles_slug_is_public() throws Exception { + mvc.perform(get("/articles/some-slug")) + .andExpect(status().is(org.hamcrest.Matchers.not(401))); + } + + @Test + void get_articles_feed_requires_authentication() throws Exception { + mvc.perform(get("/articles/feed")).andExpect(status().isUnauthorized()); + } + + @Test + void post_users_is_public() throws Exception { + mvc.perform(post("/users").content("{}").contentType("application/json")) + .andExpect(status().is(org.hamcrest.Matchers.not(401))); + } + + @Test + void post_users_login_is_public() throws Exception { + mvc.perform(post("/users/login").content("{}").contentType("application/json")) + .andExpect(status().is(org.hamcrest.Matchers.not(401))); + } + + @Test + void other_endpoints_require_authentication() throws Exception { + mvc.perform(delete("/articles/some-slug")).andExpect(status().isUnauthorized()); + } + + @Test + void authenticated_user_can_access_protected_endpoint() throws Exception { + User user = new User("test@test.com", "testuser", "pass", "", ""); + String token = "valid-token"; + + when(jwtService.getSubFromToken(eq(token))).thenReturn(Optional.of(user.getId())); + when(userRepository.findById(eq(user.getId()))).thenReturn(Optional.of(user)); + + mvc.perform(get("/articles/feed").header("Authorization", "Token " + token)) + .andExpect(status().is(org.hamcrest.Matchers.not(401))); + } +} diff --git a/src/test/java/io/spring/core/service/AuthorizationServiceTest.java b/src/test/java/io/spring/core/service/AuthorizationServiceTest.java new file mode 100644 index 000000000..0b2f42a03 --- /dev/null +++ b/src/test/java/io/spring/core/service/AuthorizationServiceTest.java @@ -0,0 +1,54 @@ +package io.spring.core.service; + +import static java.util.Collections.emptyList; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.spring.core.article.Article; +import io.spring.core.comment.Comment; +import io.spring.core.user.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class AuthorizationServiceTest { + + private User articleOwner; + private User otherUser; + private Article article; + + @BeforeEach + void setUp() { + articleOwner = new User("owner@test.com", "owner", "pass", "", ""); + otherUser = new User("other@test.com", "other", "pass", "", ""); + article = new Article("Test Title", "desc", "body", emptyList(), articleOwner.getId()); + } + + @Test + void owner_can_write_article() { + assertTrue(AuthorizationService.canWriteArticle(articleOwner, article)); + } + + @Test + void non_owner_cannot_write_article() { + assertFalse(AuthorizationService.canWriteArticle(otherUser, article)); + } + + @Test + void article_owner_can_delete_any_comment() { + Comment comment = new Comment("comment body", otherUser.getId(), article.getId()); + assertTrue(AuthorizationService.canWriteComment(articleOwner, article, comment)); + } + + @Test + void comment_author_can_delete_own_comment() { + Comment comment = new Comment("comment body", otherUser.getId(), article.getId()); + assertTrue(AuthorizationService.canWriteComment(otherUser, article, comment)); + } + + @Test + void unrelated_user_cannot_delete_comment() { + User unrelatedUser = new User("unrelated@test.com", "unrelated", "pass", "", ""); + Comment comment = new Comment("comment body", otherUser.getId(), article.getId()); + assertFalse(AuthorizationService.canWriteComment(unrelatedUser, article, comment)); + } +}