From a5c44e94a7839172fe599a855cb0af2c06d5caf8 Mon Sep 17 00:00:00 2001 From: Cristian Caprar Date: Sun, 29 Mar 2020 07:15:30 +0300 Subject: [PATCH 1/2] Task #21 --- api/.gitignore | 1 + api/build.gradle | 5 + api/gradlew | 0 .../impl/CustomUserDetailsService.java | 3 +- .../handler/AuthExceptionHandler.java | 27 ++++ api/src/main/resources/application.yml | 8 +- api/src/main/resources/db/migrate/.keepMe | 0 ...thenticationControllerIntegrationTest.java | 122 +++++++++++++++ .../AuthenticationControllerUnitTest.java | 144 ++++++++++++++++++ .../impl/AuthenticationServiceImplTest.java | 70 +++++++++ .../impl/CustomUserDetailsServiceTest.java | 92 +++++++++++ .../AbstractControllerIntegrationTest.java | 39 +++++ .../AbstractControllerUnitTest.java | 29 ++++ .../nextdoor/util/DatabaseCleaner.java | 136 +++++++++++++++++ .../DatabaseCleanerTestExecutionListener.java | 55 +++++++ .../nextdoor/util/RandomObjectFiller.java | 81 ++++++++++ .../test/resources/META-INF/spring.factories | 3 + api/src/test/resources/application-test.yml | 5 + ...uthenticationControllerIntegrationTest.sql | 1 + .../scratches/PasswordEncoderScratch.java | 12 ++ 20 files changed, 828 insertions(+), 5 deletions(-) mode change 100644 => 100755 api/gradlew create mode 100644 api/src/main/java/com/code4ro/nextdoor/core/exception/handler/AuthExceptionHandler.java create mode 100644 api/src/main/resources/db/migrate/.keepMe create mode 100644 api/src/test/java/com/code4ro/nextdoor/authentication/controller/AuthenticationControllerIntegrationTest.java create mode 100644 api/src/test/java/com/code4ro/nextdoor/authentication/controller/AuthenticationControllerUnitTest.java create mode 100644 api/src/test/java/com/code4ro/nextdoor/authentication/service/impl/AuthenticationServiceImplTest.java create mode 100644 api/src/test/java/com/code4ro/nextdoor/authentication/service/impl/CustomUserDetailsServiceTest.java create mode 100644 api/src/test/java/com/code4ro/nextdoor/common/controller/AbstractControllerIntegrationTest.java create mode 100644 api/src/test/java/com/code4ro/nextdoor/common/controller/AbstractControllerUnitTest.java create mode 100644 api/src/test/java/com/code4ro/nextdoor/util/DatabaseCleaner.java create mode 100644 api/src/test/java/com/code4ro/nextdoor/util/DatabaseCleanerTestExecutionListener.java create mode 100644 api/src/test/java/com/code4ro/nextdoor/util/RandomObjectFiller.java create mode 100644 api/src/test/resources/META-INF/spring.factories create mode 100644 api/src/test/resources/application-test.yml create mode 100644 api/src/test/resources/com/code4ro/nextdoor/authentication/controller/AuthenticationControllerIntegrationTest.sql create mode 100644 api/src/test/resources/scratches/PasswordEncoderScratch.java diff --git a/api/.gitignore b/api/.gitignore index a3ec18d..8cb80ac 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -4,6 +4,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/** !**/src/test/** +lombok.config ### IntelliJ IDEA ### .idea diff --git a/api/build.gradle b/api/build.gradle index fc510d3..96a384b 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -8,6 +8,7 @@ plugins { group = 'com.code4ro' version = '0.0.1-SNAPSHOT' sourceCompatibility = '11' +targetCompatibility = '11' repositories { mavenCentral() @@ -21,6 +22,7 @@ dependencies { implementation 'org.flywaydb:flyway-core:6.3.2' implementation 'io.springfox:springfox-swagger2:2.9.2' implementation 'io.springfox:springfox-swagger-ui:2.9.2' + implementation 'org.apache.commons:commons-lang3' runtimeOnly 'com.h2database:h2' runtimeOnly 'mysql:mysql-connector-java' @@ -29,6 +31,9 @@ dependencies { exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' } testImplementation 'org.springframework.security:spring-security-test' + + // Needed for TestRestTemplate to work with 40x responses + testImplementation'org.apache.httpcomponents:httpclient' } test { diff --git a/api/gradlew b/api/gradlew old mode 100644 new mode 100755 diff --git a/api/src/main/java/com/code4ro/nextdoor/authentication/service/impl/CustomUserDetailsService.java b/api/src/main/java/com/code4ro/nextdoor/authentication/service/impl/CustomUserDetailsService.java index 000171f..3df5921 100644 --- a/api/src/main/java/com/code4ro/nextdoor/authentication/service/impl/CustomUserDetailsService.java +++ b/api/src/main/java/com/code4ro/nextdoor/authentication/service/impl/CustomUserDetailsService.java @@ -13,6 +13,7 @@ @Service public class CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; @Autowired @@ -24,7 +25,7 @@ public CustomUserDetailsService(final UserRepository userRepository) { public UserDetails loadUserByUsername(final String email) { final User applicationUser = userRepository.findByEmail(email) .orElseThrow(() -> - new UsernameNotFoundException("User not found with email : " + email) + new UsernameNotFoundException("User not found with email: " + email) ); return UserPrincipal.create(applicationUser); diff --git a/api/src/main/java/com/code4ro/nextdoor/core/exception/handler/AuthExceptionHandler.java b/api/src/main/java/com/code4ro/nextdoor/core/exception/handler/AuthExceptionHandler.java new file mode 100644 index 0000000..687375a --- /dev/null +++ b/api/src/main/java/com/code4ro/nextdoor/core/exception/handler/AuthExceptionHandler.java @@ -0,0 +1,27 @@ +package com.code4ro.nextdoor.core.exception.handler; + +import com.code4ro.nextdoor.authentication.controller.AuthenticationController; +import com.code4ro.nextdoor.core.exception.ExceptionResponse; +import com.code4ro.nextdoor.core.exception.I18nError; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.util.Collections; + +@Order(Ordered.HIGHEST_PRECEDENCE) +@ControllerAdvice(assignableTypes = AuthenticationController.class) +public class AuthExceptionHandler extends GlobalExceptionHandler { + + @ExceptionHandler(BadCredentialsException.class) + protected ResponseEntity handleBadCredentials(final BadCredentialsException ex) { + final ExceptionResponse exceptionResponse = new ExceptionResponse(); + exceptionResponse.setI18nErrors(Collections.singletonList( + new I18nError("login.bad.credentials", null))); + return new ResponseEntity<>(exceptionResponse, HttpStatus.UNAUTHORIZED); + } +} diff --git a/api/src/main/resources/application.yml b/api/src/main/resources/application.yml index 4715580..6f76217 100644 --- a/api/src/main/resources/application.yml +++ b/api/src/main/resources/application.yml @@ -2,15 +2,15 @@ spring: profiles: active: dev datasource: - url: jdbc:mysql://localhost:3306/nextdoor?serverTimezone=Europe/Bucharest - username: root - password: root + url: jdbc:mysql://${MYSQL_HOST:localhost}:3306/nextdoor?serverTimezone=Europe/Bucharest + username: ${MYSQL_USER:root} + password: ${MYSQL_PASSWORD:root} jpa: hibernate: ddl-auto: update flyway: enabled: true - locations: classpath:db/migrate,db/data + locations: classpath:db/migrate,classpath:db/data baselineOnMigrate: true app: jwtSecret: test diff --git a/api/src/main/resources/db/migrate/.keepMe b/api/src/main/resources/db/migrate/.keepMe new file mode 100644 index 0000000..e69de29 diff --git a/api/src/test/java/com/code4ro/nextdoor/authentication/controller/AuthenticationControllerIntegrationTest.java b/api/src/test/java/com/code4ro/nextdoor/authentication/controller/AuthenticationControllerIntegrationTest.java new file mode 100644 index 0000000..afed934 --- /dev/null +++ b/api/src/test/java/com/code4ro/nextdoor/authentication/controller/AuthenticationControllerIntegrationTest.java @@ -0,0 +1,122 @@ +package com.code4ro.nextdoor.authentication.controller; + +import com.code4ro.nextdoor.authentication.dto.LoginRequest; +import com.code4ro.nextdoor.authentication.dto.RegistrationRequest; +import com.code4ro.nextdoor.common.controller.AbstractControllerIntegrationTest; +import com.code4ro.nextdoor.core.exception.ExceptionResponse; +import com.code4ro.nextdoor.util.RandomObjectFiller; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.jdbc.Sql; + +import java.net.MalformedURLException; +import java.net.URL; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Integration test for /api/authentication endpoint") +class AuthenticationControllerIntegrationTest extends AbstractControllerIntegrationTest { + + @DisplayName("tests registration scenarios") + @Test + void register() throws MalformedURLException { + RegistrationRequest registrationRequest = RandomObjectFiller.createAndFill(RegistrationRequest.class); + HttpEntity entity = new HttpEntity<>(registrationRequest, headers); + + ResponseEntity response = restTemplate.postForEntity( + new URL(getBaseUrl() + "/register").toString(), entity, Void.class); + + SoftAssertions.assertSoftly(softly -> { + assertThat(response).isNotNull(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getHeaders().getLocation()).isNotNull(); + assertThat(response.getHeaders().getLocation().toString().contains("/api/users/")).isTrue(); + }); + + // TODO: we could now call the users API, when that gets implemented, and check if we get the actual user + + // Try second registration with the same data, should result in error + + ResponseEntity response_duplicate = restTemplate.postForEntity( + new URL(getBaseUrl() + "/register").toString(), entity, ExceptionResponse.class); + + SoftAssertions.assertSoftly(softly -> { + assertThat(response_duplicate).isNotNull(); + assertThat(response_duplicate.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(response_duplicate.getHeaders().getLocation()).isNull(); + assertThat(response_duplicate.getBody()).isNotNull(); + assertThat(response_duplicate.getBody().getI18nErrors().size()).isEqualTo(1); + assertThat(response_duplicate.getBody().getI18nErrors().get(0).getI18nErrorKey()).isEqualTo("user.duplicate.email"); + }); + } + + @DisplayName("tests successful login") + @Sql(scripts = "AuthenticationControllerIntegrationTest.sql") + @Test + void login_success() throws MalformedURLException { + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail("test@test.com"); + loginRequest.setPassword("pass"); + HttpEntity entity = new HttpEntity<>(loginRequest, headers); + + ResponseEntity response = restTemplate.postForEntity( + new URL(getBaseUrl() + "/login").toString(), entity, Void.class); + + SoftAssertions.assertSoftly(softly -> { + assertThat(response).isNotNull(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + }); + } + + @DisplayName("tests wrong password login") + @Sql(scripts = "AuthenticationControllerIntegrationTest.sql") + @Test + void login_bad_password() throws MalformedURLException { + LoginRequest loginRequestWrongPass = new LoginRequest(); + loginRequestWrongPass.setEmail("test@test.com"); + loginRequestWrongPass.setPassword("wrongpass"); + HttpEntity entity = new HttpEntity<>(loginRequestWrongPass, headers); + + ResponseEntity response_wrong_pass = restTemplate.postForEntity( + new URL(getBaseUrl() + "/login").toString(), entity, ExceptionResponse.class); + + SoftAssertions.assertSoftly(softly -> { + assertThat(response_wrong_pass).isNotNull(); + assertThat(response_wrong_pass.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response_wrong_pass.getBody()).isNotNull(); + assertThat(response_wrong_pass.getBody().getI18nErrors().size()).isEqualTo(1); + assertThat(response_wrong_pass.getBody().getI18nErrors().get(0).getI18nErrorKey()).isEqualTo("login.bad.credentials"); + }); + } + + @DisplayName("tests wrong username login") + @Sql(scripts = "AuthenticationControllerIntegrationTest.sql") + @Test + void login_bad_username() throws MalformedURLException { + LoginRequest loginRequestWrongUser = new LoginRequest(); + loginRequestWrongUser.setEmail("testwrong@test.com"); + loginRequestWrongUser.setPassword("pass"); + HttpEntity entity = new HttpEntity<>(loginRequestWrongUser, headers); + + ResponseEntity response_wrong_user = restTemplate.postForEntity( + new URL(getBaseUrl() + "/login").toString(), entity, ExceptionResponse.class); + + SoftAssertions.assertSoftly(softly -> { + assertThat(response_wrong_user).isNotNull(); + assertThat(response_wrong_user.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response_wrong_user.getBody()).isNotNull(); + assertThat(response_wrong_user.getBody().getI18nErrors().size()).isEqualTo(1); + assertThat(response_wrong_user.getBody().getI18nErrors().get(0).getI18nErrorKey()).isEqualTo("login.bad.credentials"); + }); + } + + @Override + protected String getBaseUrl() { + return "http://localhost:" + port + "/api/authentication"; + } +} \ No newline at end of file diff --git a/api/src/test/java/com/code4ro/nextdoor/authentication/controller/AuthenticationControllerUnitTest.java b/api/src/test/java/com/code4ro/nextdoor/authentication/controller/AuthenticationControllerUnitTest.java new file mode 100644 index 0000000..f3bf858 --- /dev/null +++ b/api/src/test/java/com/code4ro/nextdoor/authentication/controller/AuthenticationControllerUnitTest.java @@ -0,0 +1,144 @@ +package com.code4ro.nextdoor.authentication.controller; + +import com.code4ro.nextdoor.authentication.dto.LoginRequest; +import com.code4ro.nextdoor.authentication.dto.RegistrationRequest; +import com.code4ro.nextdoor.authentication.entity.User; +import com.code4ro.nextdoor.authentication.service.AuthenticationService; +import com.code4ro.nextdoor.authentication.service.impl.CustomUserDetailsService; +import com.code4ro.nextdoor.common.controller.AbstractControllerUnitTest; +import com.code4ro.nextdoor.core.exception.NextDoorValidationException; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.core.StringEndsWith.endsWith; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("Unit test for AuthenticationController") +@WebMvcTest(AuthenticationController.class) +public class AuthenticationControllerUnitTest extends AbstractControllerUnitTest { + + private static final String EMAIL = "test@test.com"; + private static final String PASSWORD = "pass"; + private static final UUID USER_ID = new UUID(10, 100); + + @MockBean + private AuthenticationService authenticationService; + + @MockBean + protected CustomUserDetailsService customUserDetailsService; + + @MockBean + protected AuthenticationManager authenticationManager; + + @Test + void register_success() throws Exception { + RegistrationRequest registrationRequest = new RegistrationRequest(); + registrationRequest.setEmail(EMAIL); + registrationRequest.setPassword(PASSWORD); + + User user = new User(EMAIL, PASSWORD); + user.setId(USER_ID); + + when(authenticationService.register(any(RegistrationRequest.class))).thenReturn(user); + + String json = objectMapper.writeValueAsString(registrationRequest); + + mvc.perform(post("/api/authentication/register") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(header().string(HttpHeaders.LOCATION, endsWith("/api/users/" + USER_ID))); + + ArgumentCaptor requestArgumentCaptor = ArgumentCaptor.forClass(RegistrationRequest.class); + verify(authenticationService, times(1)).register(requestArgumentCaptor.capture()); + + SoftAssertions.assertSoftly(softly -> { + assertThat(requestArgumentCaptor.getValue()).isNotNull(); + assertThat(requestArgumentCaptor.getValue().getEmail()).isEqualTo(EMAIL); + assertThat(requestArgumentCaptor.getValue().getPassword()).isEqualTo(PASSWORD); + }); + } + + @Test + void register_duplicate_error() throws Exception { + RegistrationRequest registrationRequest = new RegistrationRequest(); + registrationRequest.setEmail(EMAIL); + registrationRequest.setPassword(PASSWORD); + + User user = new User(EMAIL, PASSWORD); + user.setId(USER_ID); + + when(authenticationService.register(any(RegistrationRequest.class))) + .thenThrow(new NextDoorValidationException("user.duplicate.email", HttpStatus.CONFLICT)); + + String json = objectMapper.writeValueAsString(registrationRequest); + + mvc.perform(post("/api/authentication/register") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.i18nErrors[0].i18nErrorKey") + .value("user.duplicate.email")); + } + + @Test + void login_success() throws Exception { + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail(EMAIL); + loginRequest.setPassword(PASSWORD); + + Authentication authentication = new UsernamePasswordAuthenticationToken(EMAIL, PASSWORD); + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))).thenReturn(authentication); + + String json = objectMapper.writeValueAsString(loginRequest); + + mvc.perform(post("/api/authentication/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + void login_badCredentials_error() throws Exception { + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setEmail(EMAIL); + loginRequest.setPassword(PASSWORD); + + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) + .thenThrow(new BadCredentialsException("Invalid credentials")); + + String json = objectMapper.writeValueAsString(loginRequest); + + mvc.perform(post("/api/authentication/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.i18nErrors[0].i18nErrorKey") + .value("login.bad.credentials")); + } +} diff --git a/api/src/test/java/com/code4ro/nextdoor/authentication/service/impl/AuthenticationServiceImplTest.java b/api/src/test/java/com/code4ro/nextdoor/authentication/service/impl/AuthenticationServiceImplTest.java new file mode 100644 index 0000000..abfa92d --- /dev/null +++ b/api/src/test/java/com/code4ro/nextdoor/authentication/service/impl/AuthenticationServiceImplTest.java @@ -0,0 +1,70 @@ +package com.code4ro.nextdoor.authentication.service.impl; + +import com.code4ro.nextdoor.authentication.dto.RegistrationRequest; +import com.code4ro.nextdoor.authentication.entity.User; +import com.code4ro.nextdoor.authentication.repository.UserRepository; +import com.code4ro.nextdoor.core.exception.NextDoorValidationException; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.AdditionalAnswers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@DisplayName("Unit test for AuthenticationServiceImpl") +@ExtendWith(MockitoExtension.class) +class AuthenticationServiceImplTest { + + @InjectMocks + private AuthenticationServiceImpl service; + + private static final String PASSWORD = "pass"; + private static final String EMAIL = "test@test.com"; + + @Mock + private UserRepository userRepository; + @Mock + private PasswordEncoder passwordEncoder; + + @DisplayName("successful registration") + @Test + void registrationSuccess() { + RegistrationRequest request = new RegistrationRequest(); + request.setEmail(EMAIL); + request.setPassword(PASSWORD); + when(userRepository.existsByEmail(EMAIL)).thenReturn(false); + when(passwordEncoder.encode(PASSWORD)).thenReturn(PASSWORD); + when(userRepository.save(any(User.class))).then(AdditionalAnswers.returnsFirstArg()); + + User result = service.register(request); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result.getEmail()).isEqualTo(EMAIL); + softly.assertThat(request.getPassword()).isEqualTo(PASSWORD); + }); + } + + @DisplayName("error registering an existing user") + @Test + void registerFailsForExistingUser() { + RegistrationRequest request = new RegistrationRequest(); + request.setEmail("existing"); + when(userRepository.existsByEmail("existing")).thenReturn(true); + + NextDoorValidationException exception = catchThrowableOfType(() -> service.register(request), NextDoorValidationException.class); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(exception.getI18nKey()).isEqualTo("user.duplicate.email"); + softly.assertThat(exception.getI8nArguments()).isNull(); + softly.assertThat(exception.getHttpStatus()).isEqualTo(HttpStatus.CONFLICT); + }); + } +} \ No newline at end of file diff --git a/api/src/test/java/com/code4ro/nextdoor/authentication/service/impl/CustomUserDetailsServiceTest.java b/api/src/test/java/com/code4ro/nextdoor/authentication/service/impl/CustomUserDetailsServiceTest.java new file mode 100644 index 0000000..8b27ca7 --- /dev/null +++ b/api/src/test/java/com/code4ro/nextdoor/authentication/service/impl/CustomUserDetailsServiceTest.java @@ -0,0 +1,92 @@ +package com.code4ro.nextdoor.authentication.service.impl; + +import com.code4ro.nextdoor.authentication.entity.User; +import com.code4ro.nextdoor.authentication.repository.UserRepository; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +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.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.when; + +@DisplayName("Unit test for CustomUserDetailsService") +@ExtendWith(MockitoExtension.class) +class CustomUserDetailsServiceTest { + + @InjectMocks + private CustomUserDetailsService service; + + private static final String EMAIL = "test@test.com"; + private static final UUID USER_ID = new UUID(10, 100); + private static final String PASSWORD = "a password"; + private static final String EMAIL_UNKNOWN = "unknown"; + + @Mock + private UserRepository userRepository; + @Mock + private User user; + + @DisplayName("successful loading by user name") + @Test + void loadUserByUsername() { + when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(user)); + when(user.getId()).thenReturn(USER_ID); + when(user.getEmail()).thenReturn(EMAIL); + when(user.getPassword()).thenReturn(PASSWORD); + + UserDetails result = service.loadUserByUsername(EMAIL); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).isNotNull(); + softly.assertThat(result.getUsername()).isEqualTo(EMAIL); + softly.assertThat(result.getPassword()).isEqualTo(PASSWORD); + }); + } + + @DisplayName("error loading by user name") + @Test + void loadUserByUnknownUsername() { + when(userRepository.findByEmail(EMAIL_UNKNOWN)).thenReturn(Optional.empty()); + + assertThatExceptionOfType(UsernameNotFoundException.class) + .isThrownBy(() -> service.loadUserByUsername(EMAIL_UNKNOWN)) + .withMessage("User not found with email: " + EMAIL_UNKNOWN); + } + + @DisplayName("successful loading by user id") + @Test + void loadUserById() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(user.getId()).thenReturn(USER_ID); + when(user.getEmail()).thenReturn(EMAIL); + when(user.getPassword()).thenReturn(PASSWORD); + + UserDetails result = service.loadUserById(USER_ID); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(result).isNotNull(); + softly.assertThat(result.getUsername()).isEqualTo(EMAIL); + softly.assertThat(result.getPassword()).isEqualTo(PASSWORD); + }); + } + + @DisplayName("error loading by user id") + @Test + void loadUserByUnknownId() { + UUID unknownId = new UUID(30, 300); + when(userRepository.findById(unknownId)).thenReturn(Optional.empty()); + + assertThatExceptionOfType(UsernameNotFoundException.class) + .isThrownBy(() -> service.loadUserById(unknownId)) + .withMessage("User not found with id: " + unknownId.toString()); + } +} \ No newline at end of file diff --git a/api/src/test/java/com/code4ro/nextdoor/common/controller/AbstractControllerIntegrationTest.java b/api/src/test/java/com/code4ro/nextdoor/common/controller/AbstractControllerIntegrationTest.java new file mode 100644 index 0000000..1e9ba8e --- /dev/null +++ b/api/src/test/java/com/code4ro/nextdoor/common/controller/AbstractControllerIntegrationTest.java @@ -0,0 +1,39 @@ +package com.code4ro.nextdoor.common.controller; + +import com.code4ro.nextdoor.NextDoorApplication; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; + +/** + * Base class for all REST controllers integration tests. + * + * The integration tests are run with an autoconfigured test database (H2), they boot the container and use persistence, + * clean the database before each test is executed. + * + * Because we do not want to run Flyway migration scripts that use MySQL syntax on H2, we use a dedicated test profile. + */ +@SpringBootTest(classes = NextDoorApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase +@ActiveProfiles({ "test" }) +public abstract class AbstractControllerIntegrationTest { + + @LocalServerPort + protected int port; + + @Autowired + protected TestRestTemplate restTemplate; + + protected HttpHeaders headers = new HttpHeaders(); + + /** + * Gets the base url for the tested REST endpoint. + * + * @return the endpoint's base url + */ + protected abstract String getBaseUrl(); +} diff --git a/api/src/test/java/com/code4ro/nextdoor/common/controller/AbstractControllerUnitTest.java b/api/src/test/java/com/code4ro/nextdoor/common/controller/AbstractControllerUnitTest.java new file mode 100644 index 0000000..6f4fb35 --- /dev/null +++ b/api/src/test/java/com/code4ro/nextdoor/common/controller/AbstractControllerUnitTest.java @@ -0,0 +1,29 @@ +package com.code4ro.nextdoor.common.controller; + +import com.code4ro.nextdoor.security.jwt.JwtAuthenticationEntryPoint; +import com.code4ro.nextdoor.security.jwt.JwtTokenProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Base class for REST controllers unit tests. + */ +public abstract class AbstractControllerUnitTest { + + @Autowired + protected MockMvc mvc; + + @Autowired + protected ObjectMapper objectMapper; + + @MockBean + private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + @MockBean + private JwtTokenProvider jwtTokenProvider; + + protected HttpHeaders headers = new HttpHeaders(); +} diff --git a/api/src/test/java/com/code4ro/nextdoor/util/DatabaseCleaner.java b/api/src/test/java/com/code4ro/nextdoor/util/DatabaseCleaner.java new file mode 100644 index 0000000..df31eb7 --- /dev/null +++ b/api/src/test/java/com/code4ro/nextdoor/util/DatabaseCleaner.java @@ -0,0 +1,136 @@ +package com.code4ro.nextdoor.util; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * Utility class that cleans all database tables content. + * Tables that must not be cleaned can be added with {@link #excludeTables(String...)}. + */ +class DatabaseCleaner { + + private final Logger LOG = LoggerFactory.getLogger(getClass()); + + private Supplier connectionSupplier; + + private Set tablesToExclude = new HashSet<>(); + private List tablesForClearing; + + DatabaseCleaner(Supplier connectionSupplier) { + this.connectionSupplier = connectionSupplier; + } + + public void reset() { + if (isNotPrepared()) { + prepare(); + } + executeReset(); + } + + public void excludeTables(String... tableNames) { + tablesToExclude.addAll(Arrays.stream(tableNames).map(String::toLowerCase).collect(Collectors.toList())); + } + + private void prepare() { + try (Connection connection = connectionSupplier.get()) { + DatabaseMetaData metaData = connection.getMetaData(); + List tableRefs = new ArrayList<>(); + try (ResultSet rs = + metaData.getTables(connection.getCatalog(), null, null, new String[]{"TABLE"})) { + + while (rs.next()) { + String tableName = rs.getString("TABLE_NAME"); + if (!tablesToExclude.contains(tableName)) { + tableRefs.add(new TableRef(tableName)); + } + } + } + + tablesForClearing = tableRefs; + + LOG.info("Prepared clean db command: {}", tablesForClearing); + + } catch (SQLException e) { + LOG.error("Prepare cleanup error", e); + throw new RuntimeException(e); + } + } + + private void executeReset() { + try (Connection connection = connectionSupplier.get(); Statement reset = buildClearStatement(connection)) { + reset.executeBatch(); + } catch (SQLException e) { + // Only for MySQL + // String status = dbEngineStatus(); + // LOG.error("Failed to remove rows. Engine status: {}" , status, e); + LOG.error("Failed to remove rows", e); + throw new RuntimeException(e); + } + } + + private boolean isNotPrepared() { + return tablesForClearing == null; + } + + private Statement buildClearStatement(Connection connection) throws SQLException { + Statement reset = connection.createStatement(); + + /* + Disable referential integrity checks: + - MySQL: "SET FOREIGN_KEY_CHECKS = 0" + - H2: "SET REFERENTIAL_INTEGRITY FALSE" - see http://h2database.com/html/commands.html#set_referential_integrity + + Enable referential integrity checks: + - MySQL: "SET FOREIGN_KEY_CHECKS = 1" + - H2: "SET REFERENTIAL_INTEGRITY TRUE" + */ + + reset.addBatch("SET REFERENTIAL_INTEGRITY FALSE"); + for (TableRef ref : tablesForClearing) { + reset.addBatch("DELETE FROM " + ref.getName()); + } + reset.addBatch("SET REFERENTIAL_INTEGRITY TRUE"); + return reset; + } + + private String dbEngineStatus() { + try (Connection connection = connectionSupplier.get(); Statement statement = connection.createStatement()) { + try (ResultSet rs = statement.executeQuery("SHOW ENGINE INNODB STATUS")) { + StringBuilder status = new StringBuilder(); + while (rs.next()) { + status.append(rs.getString("Status")).append("||"); + } + return status.toString(); + } + } catch (SQLException e) { + LOG.error("Failed to get DB engine status", e); + return StringUtils.EMPTY; + } + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + private static class TableRef { + private String name; + } +} diff --git a/api/src/test/java/com/code4ro/nextdoor/util/DatabaseCleanerTestExecutionListener.java b/api/src/test/java/com/code4ro/nextdoor/util/DatabaseCleanerTestExecutionListener.java new file mode 100644 index 0000000..be74167 --- /dev/null +++ b/api/src/test/java/com/code4ro/nextdoor/util/DatabaseCleanerTestExecutionListener.java @@ -0,0 +1,55 @@ +package com.code4ro.nextdoor.util; + +import com.code4ro.nextdoor.common.controller.AbstractControllerIntegrationTest; +import org.springframework.stereotype.Service; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.support.AbstractTestExecutionListener; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.function.Supplier; + +import javax.sql.DataSource; + +/** + * Spring test execution listener that performs database cleanup before each test. + * + * Only applicable to integration tests that extend AbstractControllerIntegrationTest, due to the need for a valid datasource. + */ +@Service +public class DatabaseCleanerTestExecutionListener extends AbstractTestExecutionListener { + + private DatabaseCleaner databaseCleaner; + + /** + * Returns {@code 4999} to be executed before the SqlScriptsTestExecutionListener. + */ + @Override + public final int getOrder() { + return 4999; + } + + @Override + public void beforeTestMethod(TestContext testContext) { + if (!AbstractControllerIntegrationTest.class.isAssignableFrom(testContext.getTestClass())) { + return; + } + + if (databaseCleaner == null) { + // TODO: Consider inspecting dataSource to check if we are connecting to test database and not a prod one! + databaseCleaner = new DatabaseCleaner(getConnectionSupplier(testContext)); + } + databaseCleaner.reset(); + } + + private Supplier getConnectionSupplier(TestContext testContext) { + DataSource dataSource = testContext.getApplicationContext().getBean(DataSource.class); + return () -> { + try { + return dataSource.getConnection(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + }; + } +} diff --git a/api/src/test/java/com/code4ro/nextdoor/util/RandomObjectFiller.java b/api/src/test/java/com/code4ro/nextdoor/util/RandomObjectFiller.java new file mode 100644 index 0000000..06cf7de --- /dev/null +++ b/api/src/test/java/com/code4ro/nextdoor/util/RandomObjectFiller.java @@ -0,0 +1,81 @@ +package com.code4ro.nextdoor.util; + +import com.code4ro.nextdoor.core.entity.BaseEntity; + +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.RandomUtils; + +import java.lang.reflect.Field; +import java.math.BigInteger; +import java.util.Calendar; +import java.util.Date; +import java.util.UUID; + +import javax.validation.constraints.Email; +import javax.validation.constraints.Size; + +public class RandomObjectFiller { + + public static T createAndFill(Class clazz) { + try { + final T instance = clazz.getDeclaredConstructor().newInstance(); + for(final Field field: clazz.getDeclaredFields()) { + field.setAccessible(true); + Object value = getRandomValueForField(field); + field.set(instance, value); + } + return instance; + } catch (Exception e) { + return null; + } + } + + public static T createAndFillWithBaseEntity(Class clazz) { + final T instance = (T) createAndFill(clazz); + ((BaseEntity) instance).setId(UUID.randomUUID()); + return instance; + } + + private static Object getRandomValueForField(Field field) { + final Class type = field.getType(); + + if(type.isEnum()) { + Object[] enumValues = type.getEnumConstants(); + return enumValues[RandomUtils.nextInt(0, enumValues.length)]; + } else if(type.equals(Integer.TYPE) || type.equals(Integer.class)) { + return RandomUtils.nextInt(); + } else if(type.equals(Long.TYPE) || type.equals(Long.class)) { + return RandomUtils.nextLong(); + } else if(type.equals(Double.TYPE) || type.equals(Double.class)) { + return RandomUtils.nextDouble(); + } else if(type.equals(Float.TYPE) || type.equals(Float.class)) { + return RandomUtils.nextFloat(); + } else if(type.equals(UUID.class)) { + return UUID.randomUUID(); + } else if(type.equals(BigInteger.class)){ + return BigInteger.valueOf(RandomUtils.nextInt()); + } else if(type.equals(String.class)) { + return getFieldWithConstraint(field); + } else if(type.equals(Date.class)) { + int randomYear = RandomUtils.nextInt(1990, 2030); + int randomDay = RandomUtils.nextInt(1, 366); + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.YEAR, randomYear); + calendar.set(Calendar.DAY_OF_YEAR, randomDay); + return calendar.getTime(); + } + return createAndFill(type); + } + + private static String getFieldWithConstraint(final Field field) { + final Email email = field.getAnnotation(Email.class); + if (email != null) { + return RandomStringUtils.randomAlphabetic(10) + "@email.com"; + } + final Size size = field.getAnnotation(Size.class); + if (size != null) { + return RandomStringUtils.randomAlphabetic(size.min() > 0 ? size.min() : 1, size.max()); + } + return RandomStringUtils.randomAlphabetic(10); + } +} diff --git a/api/src/test/resources/META-INF/spring.factories b/api/src/test/resources/META-INF/spring.factories new file mode 100644 index 0000000..4bc78ca --- /dev/null +++ b/api/src/test/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Test Execution Listeners +org.springframework.test.context.TestExecutionListener=\ +com.code4ro.nextdoor.util.DatabaseCleanerTestExecutionListener \ No newline at end of file diff --git a/api/src/test/resources/application-test.yml b/api/src/test/resources/application-test.yml new file mode 100644 index 0000000..4edcdeb --- /dev/null +++ b/api/src/test/resources/application-test.yml @@ -0,0 +1,5 @@ +spring: + flyway: + enabled: false + locations: classpath:db/migrate + baselineOnMigrate: true \ No newline at end of file diff --git a/api/src/test/resources/com/code4ro/nextdoor/authentication/controller/AuthenticationControllerIntegrationTest.sql b/api/src/test/resources/com/code4ro/nextdoor/authentication/controller/AuthenticationControllerIntegrationTest.sql new file mode 100644 index 0000000..f3e8209 --- /dev/null +++ b/api/src/test/resources/com/code4ro/nextdoor/authentication/controller/AuthenticationControllerIntegrationTest.sql @@ -0,0 +1 @@ +INSERT INTO USER (ID, EMAIL, PASSWORD) VALUES (RANDOM_UUID(), 'test@test.com', '$2a$10$6QTvs/f7D4MEv2ADUbC2OeI2jy5trD1.Sf15qcwAi9UaZmPTAJSs.') \ No newline at end of file diff --git a/api/src/test/resources/scratches/PasswordEncoderScratch.java b/api/src/test/resources/scratches/PasswordEncoderScratch.java new file mode 100644 index 0000000..b5e29ed --- /dev/null +++ b/api/src/test/resources/scratches/PasswordEncoderScratch.java @@ -0,0 +1,12 @@ +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +/** + * Used to generate the encoded passwords for the SQL scripts. + */ +class PasswordEncoderScratch { + + public static void main(String[] args) { + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + System.out.println(encoder.encode("pass")); + } +} \ No newline at end of file From ed244fd2a45bfc69195de53af08e601bc78b8edf Mon Sep 17 00:00:00 2001 From: Cristian Caprar Date: Sun, 29 Mar 2020 14:47:35 +0300 Subject: [PATCH 2/2] Task #21: PR review --- api/build.gradle | 5 +++-- .../code4ro/nextdoor/util/DatabaseCleaner.java | 16 +++++++++------- .../nextdoor/util/RandomObjectFiller.java | 6 ++++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/api/build.gradle b/api/build.gradle index 96a384b..8e4742e 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -22,7 +22,6 @@ dependencies { implementation 'org.flywaydb:flyway-core:6.3.2' implementation 'io.springfox:springfox-swagger2:2.9.2' implementation 'io.springfox:springfox-swagger-ui:2.9.2' - implementation 'org.apache.commons:commons-lang3' runtimeOnly 'com.h2database:h2' runtimeOnly 'mysql:mysql-connector-java' @@ -33,7 +32,9 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' // Needed for TestRestTemplate to work with 40x responses - testImplementation'org.apache.httpcomponents:httpclient' + testImplementation'org.apache.httpcomponents:httpclient:4.5.12' + + testImplementation 'org.apache.commons:commons-lang3:3.10' } test { diff --git a/api/src/test/java/com/code4ro/nextdoor/util/DatabaseCleaner.java b/api/src/test/java/com/code4ro/nextdoor/util/DatabaseCleaner.java index df31eb7..ceb9f80 100644 --- a/api/src/test/java/com/code4ro/nextdoor/util/DatabaseCleaner.java +++ b/api/src/test/java/com/code4ro/nextdoor/util/DatabaseCleaner.java @@ -112,14 +112,15 @@ private Statement buildClearStatement(Connection connection) throws SQLException } private String dbEngineStatus() { - try (Connection connection = connectionSupplier.get(); Statement statement = connection.createStatement()) { - try (ResultSet rs = statement.executeQuery("SHOW ENGINE INNODB STATUS")) { - StringBuilder status = new StringBuilder(); - while (rs.next()) { - status.append(rs.getString("Status")).append("||"); - } - return status.toString(); + try (Connection connection = connectionSupplier.get(); Statement statement = connection.createStatement(); + ResultSet rs = statement.executeQuery("SHOW ENGINE INNODB STATUS")) { + + StringBuilder status = new StringBuilder(); + while (rs.next()) { + status.append(rs.getString("Status")).append("||"); } + return status.toString(); + } catch (SQLException e) { LOG.error("Failed to get DB engine status", e); return StringUtils.EMPTY; @@ -131,6 +132,7 @@ private String dbEngineStatus() { @NoArgsConstructor @AllArgsConstructor private static class TableRef { + private String name; } } diff --git a/api/src/test/java/com/code4ro/nextdoor/util/RandomObjectFiller.java b/api/src/test/java/com/code4ro/nextdoor/util/RandomObjectFiller.java index 06cf7de..22700c8 100644 --- a/api/src/test/java/com/code4ro/nextdoor/util/RandomObjectFiller.java +++ b/api/src/test/java/com/code4ro/nextdoor/util/RandomObjectFiller.java @@ -30,9 +30,11 @@ public static T createAndFill(Class clazz) { } } - public static T createAndFillWithBaseEntity(Class clazz) { + public static T createAndFillWithBaseEntity(Class clazz) { final T instance = (T) createAndFill(clazz); - ((BaseEntity) instance).setId(UUID.randomUUID()); + if (instance != null) { + instance.setId(UUID.randomUUID()); + } return instance; }