diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e03b401 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.gitignore +README.md +.gradle +build \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..d3e4347 --- /dev/null +++ b/.env @@ -0,0 +1,10 @@ +POSTGRES_DB=no_copy_ +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_PORT=5432 +POSTGRES_HOST=postgres + +REDIS_HOST=redis +REDIS_PORT=6379 + +SERVER_PORT=8080 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8242e14 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM gradle:8.14.2-jdk21 AS build + +WORKDIR /app + +COPY . . + +RUN gradle --no-daemon clean build -x test + +FROM eclipse-temurin:21-jre + +WORKDIR /app + +COPY --from=build /app/build/libs/*.jar app.jar + +EXPOSE 8080 + +CMD ["java", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index 5101285..4e63b11 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,11 @@ plugins { id 'java' - id 'org.springframework.boot' version '4.0.0' + id 'org.springframework.boot' version '3.5.6' id 'io.spring.dependency-management' version '1.1.7' } group = 'ru.soune' version = '0.0.1-SNAPSHOT' -description = 'Demo project for Spring Boot' java { toolchain { @@ -27,15 +26,16 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.security:spring-security-crypto:6.5.3' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc' runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' - testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.mockito:mockito-core:5.3.1' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..6e06b4b --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,32 @@ +version: '3.9' + +services: + db: + image: postgres:17 + restart: always + environment: + POSTGRES_DB: no_copy_ + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "54320:5432" + volumes: + - pgdata:/var/lib/postgresql/data + container_name: postgres + + app: + image: popovtsev/ncp + container_name: no_copy_app + environment: + POSTGRES_DB: no_copy_ + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_PORT: 5432 + POSTGRES_HOST: db + depends_on: + - db + ports: + - "8080:8080" + +volumes: + pgdata: \ No newline at end of file diff --git a/src/main/java/ru/soune/no_copy/configuration/ApplicationConfig.java b/src/main/java/ru/soune/no_copy/configuration/ApplicationConfig.java new file mode 100644 index 0000000..8b0409b --- /dev/null +++ b/src/main/java/ru/soune/no_copy/configuration/ApplicationConfig.java @@ -0,0 +1,17 @@ +package ru.soune.no_copy.configuration; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +@EnableAutoConfiguration +public class ApplicationConfig { + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/ru/soune/no_copy/configuration/JpaConfiguration.java b/src/main/java/ru/soune/no_copy/configuration/JpaConfiguration.java new file mode 100644 index 0000000..56ae18b --- /dev/null +++ b/src/main/java/ru/soune/no_copy/configuration/JpaConfiguration.java @@ -0,0 +1,9 @@ +package ru.soune.no_copy.configuration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfiguration { +} diff --git a/src/main/java/ru/soune/no_copy/controller/AuthController.java b/src/main/java/ru/soune/no_copy/controller/AuthController.java new file mode 100644 index 0000000..5c4e380 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/controller/AuthController.java @@ -0,0 +1,39 @@ +package ru.soune.no_copy.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import ru.soune.no_copy.dto.AuthResponse; +import ru.soune.no_copy.dto.LoginRequest; +import ru.soune.no_copy.dto.LoginResponse; +import ru.soune.no_copy.dto.RegisterRequest; +import ru.soune.no_copy.entity.AuthToken; +import ru.soune.no_copy.service.AuthService; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest registerRequest) { + AuthToken authToken = authService.register(registerRequest); + + return ResponseEntity.ok(new AuthResponse(true, "success.user.register", authToken.getToken(), + authToken.getExpiresAt())); + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + AuthToken login = authService.login(request); + + return ResponseEntity.ok(new LoginResponse(true, login.getUser().getEmail(), + login.getToken(),login.getExpiresAt().toString())); + } +} diff --git a/src/main/java/ru/soune/no_copy/dto/AuthResponse.java b/src/main/java/ru/soune/no_copy/dto/AuthResponse.java new file mode 100644 index 0000000..8e76c1a --- /dev/null +++ b/src/main/java/ru/soune/no_copy/dto/AuthResponse.java @@ -0,0 +1,5 @@ +package ru.soune.no_copy.dto; + +import java.time.LocalDateTime; + +public record AuthResponse (boolean success, String message, String token, LocalDateTime expiresAt) {} diff --git a/src/main/java/ru/soune/no_copy/dto/LoginRequest.java b/src/main/java/ru/soune/no_copy/dto/LoginRequest.java new file mode 100644 index 0000000..47602d2 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/dto/LoginRequest.java @@ -0,0 +1,18 @@ +package ru.soune.no_copy.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class LoginRequest { + + @NotBlank(message = "error.not.blank") @Email(message = "error.not.email") @Size(max = 128) + private String email; + + @NotBlank(message = "error.not.blank") @Size(min = 8) + private String password; +} diff --git a/src/main/java/ru/soune/no_copy/dto/LoginResponse.java b/src/main/java/ru/soune/no_copy/dto/LoginResponse.java new file mode 100644 index 0000000..5fe7d73 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/dto/LoginResponse.java @@ -0,0 +1,14 @@ +package ru.soune.no_copy.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import ru.soune.no_copy.entity.User; + +@Data +@AllArgsConstructor +public class LoginResponse { + private boolean success; + private String email; + private String token; + private String expiresAt; +} diff --git a/src/main/java/ru/soune/no_copy/dto/RegisterRequest.java b/src/main/java/ru/soune/no_copy/dto/RegisterRequest.java new file mode 100644 index 0000000..bdf67e9 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/dto/RegisterRequest.java @@ -0,0 +1,13 @@ +package ru.soune.no_copy.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RegisterRequest( + @NotBlank(message = "error.name.length") @Size(min = 2, max = 64) String firstName, + @NotBlank(message = "error.name.length") @Size(min = 2, max = 64) String secondName, + @NotBlank(message = "error.name.length") @Size(min = 2, max = 64) String lastName, + @NotBlank(message = "error.not.blank") @Email(message = "error.not.email") @Size(max = 128) String email, + @NotBlank(message = "error.not.blank") @Size(min = 8) String password +) {} diff --git a/src/main/java/ru/soune/no_copy/entity/AuthToken.java b/src/main/java/ru/soune/no_copy/entity/AuthToken.java new file mode 100644 index 0000000..ee78fe9 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/entity/AuthToken.java @@ -0,0 +1,37 @@ +package ru.soune.no_copy.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@NoArgsConstructor +@Getter +@Setter +@EntityListeners(AuditingEntityListener.class) +@Table(name = "auth_tokens") +public class AuthToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String token; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt = LocalDateTime.now().plusDays(30); + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; +} diff --git a/src/main/java/ru/soune/no_copy/entity/User.java b/src/main/java/ru/soune/no_copy/entity/User.java new file mode 100644 index 0000000..808c70f --- /dev/null +++ b/src/main/java/ru/soune/no_copy/entity/User.java @@ -0,0 +1,58 @@ +package ru.soune.no_copy.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import jakarta.validation.constraints.Size; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@AllArgsConstructor +@NoArgsConstructor +@ToString +@EqualsAndHashCode +@Getter @Setter +@EntityListeners(AuditingEntityListener.class) +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userId; + + @Size(max = 64) + @Column(name = "firstName", nullable = false, length = 64) + private String firstName; + + @Size(max = 64) + @Column(name = "lastName", nullable = false, length = 64) + private String lastName; + + @Size(max = 64) + @Column(name = "secondName", length = 64) + private String secondName; + + @Size(max = 1024) + @Column(name = "email", nullable = false, length = 1024, unique = true) + private String email; + + @Column(nullable = false) + @JsonIgnore + private String password; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List tokens = new ArrayList<>(); + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @Column(name = "is_active") + private Boolean isActive; + +} diff --git a/src/main/java/ru/soune/no_copy/exception/NotValidationPasswordException.java b/src/main/java/ru/soune/no_copy/exception/NotValidationPasswordException.java new file mode 100644 index 0000000..6e24040 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/exception/NotValidationPasswordException.java @@ -0,0 +1,7 @@ +package ru.soune.no_copy.exception; + +public class NotValidationPasswordException extends RuntimeException { + public NotValidationPasswordException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/soune/no_copy/exception/UserAlreadyExistsException.java b/src/main/java/ru/soune/no_copy/exception/UserAlreadyExistsException.java new file mode 100644 index 0000000..8815885 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/exception/UserAlreadyExistsException.java @@ -0,0 +1,7 @@ +package ru.soune.no_copy.exception; + +public class UserAlreadyExistsException extends RuntimeException { + public UserAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/soune/no_copy/exception/UserNotFoundException.java b/src/main/java/ru/soune/no_copy/exception/UserNotFoundException.java new file mode 100644 index 0000000..a8d8b03 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/exception/UserNotFoundException.java @@ -0,0 +1,7 @@ +package ru.soune.no_copy.exception; + +public class UserNotFoundException extends RuntimeException { + public UserNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/soune/no_copy/handler/GlobalExceptionHandler.java b/src/main/java/ru/soune/no_copy/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..b25623e --- /dev/null +++ b/src/main/java/ru/soune/no_copy/handler/GlobalExceptionHandler.java @@ -0,0 +1,66 @@ +package ru.soune.no_copy.handler; + +import lombok.AllArgsConstructor; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import ru.soune.no_copy.exception.NotValidationPasswordException; +import ru.soune.no_copy.exception.UserAlreadyExistsException; +import ru.soune.no_copy.exception.UserNotFoundException; + +import java.util.Map; + +@RestControllerAdvice +@AllArgsConstructor +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .findFirst() + .orElse(""); + + return ResponseEntity.badRequest() + .body(Map.of( + "success", false, + "message" ,message)); + } + + @ExceptionHandler(UserAlreadyExistsException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public ResponseEntity handleUserContainsException(UserAlreadyExistsException ex) { + return ResponseEntity. + badRequest() + .body(Map.of( + "success", false, + "message" ,ex.getMessage() + )); + } + + @ExceptionHandler(UserNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ResponseEntity handleUserNotFoundException(UserNotFoundException ex) { + return ResponseEntity. + badRequest() + .body(Map.of( + "success", false, + "message" ,ex.getMessage() + )); + } + + @ExceptionHandler(NotValidationPasswordException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ResponseEntity handleNotValidationPasswordException(NotValidationPasswordException ex) { + return ResponseEntity. + badRequest() + .body(Map.of( + "success", false, + "message" ,ex.getMessage() + )); + } +} diff --git a/src/main/java/ru/soune/no_copy/repository/AuthTokenRepository.java b/src/main/java/ru/soune/no_copy/repository/AuthTokenRepository.java new file mode 100644 index 0000000..725d8e0 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/repository/AuthTokenRepository.java @@ -0,0 +1,13 @@ +package ru.soune.no_copy.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import ru.soune.no_copy.entity.AuthToken; + +import java.time.LocalDate; +import java.util.Optional; + +@Repository +public interface AuthTokenRepository extends JpaRepository { + Optional findByTokenAndExpiresAtAfter(String token, LocalDate expiresAtAfter); +} diff --git a/src/main/java/ru/soune/no_copy/repository/UserRepository.java b/src/main/java/ru/soune/no_copy/repository/UserRepository.java new file mode 100644 index 0000000..b5aba82 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/repository/UserRepository.java @@ -0,0 +1,12 @@ +package ru.soune.no_copy.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import ru.soune.no_copy.entity.User; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + + boolean existsByEmail(String email); +} diff --git a/src/main/java/ru/soune/no_copy/service/AuthService.java b/src/main/java/ru/soune/no_copy/service/AuthService.java new file mode 100644 index 0000000..524c28c --- /dev/null +++ b/src/main/java/ru/soune/no_copy/service/AuthService.java @@ -0,0 +1,87 @@ +package ru.soune.no_copy.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.soune.no_copy.dto.LoginRequest; +import ru.soune.no_copy.dto.RegisterRequest; +import ru.soune.no_copy.entity.AuthToken; +import ru.soune.no_copy.entity.User; +import ru.soune.no_copy.exception.NotValidationPasswordException; +import ru.soune.no_copy.exception.UserAlreadyExistsException; +import ru.soune.no_copy.exception.UserNotFoundException; +import ru.soune.no_copy.repository.AuthTokenRepository; +import ru.soune.no_copy.repository.UserRepository; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + + private final AuthTokenRepository authTokenRepository; + + private final PasswordEncoder passwordEncoder; + + private final MessageSource messageSource; + + private final SecureRandom secureRandom = new SecureRandom(); + + @Transactional + public AuthToken register(RegisterRequest registerRequest) { + if (userRepository.existsByEmail(registerRequest.email())) { + throw new UserAlreadyExistsException(messageSource.getMessage("error.user.exists", null, + Locale.getDefault())); + } + + User user = new User(); + user.setFirstName(registerRequest.firstName()); + user.setSecondName(registerRequest.secondName()); + user.setLastName(registerRequest.lastName()); + user.setEmail(registerRequest.email()); + user.setPassword(passwordEncoder.encode(registerRequest.password())); + + User savedUser = userRepository.save(user); + + AuthToken authToken = new AuthToken(); + authToken.setToken(generateAuthToken()); + authToken.setUser(savedUser); + + return authTokenRepository.save(authToken); + } + + @Transactional + public AuthToken login(LoginRequest request) { + Optional userOpt = userRepository.findByEmail(request.getEmail()); + + if (userOpt.isEmpty()) { + throw new UserNotFoundException("User with email " + request.getEmail() + " not found"); + } + + User user = userOpt.get(); + + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + throw new NotValidationPasswordException("Invalid password"); + } + + AuthToken authToken = new AuthToken(); + authToken.setToken(generateAuthToken()); + authToken.setUser(user); + + return authTokenRepository.save(authToken); + } + + + private String generateAuthToken() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index cd9d05a..2b84ef5 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,18 @@ -пspring: - application: - name: no-copy +spring: + datasource: + url: jdbc:postgresql://${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + username: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: update + show-sql: true + +server: + port: ${SERVER_PORT:8080} + +logging: + level: + root: INFO diff --git a/src/main/resources/meassge.properties b/src/main/resources/meassge.properties new file mode 100644 index 0000000..9a212bf --- /dev/null +++ b/src/main/resources/meassge.properties @@ -0,0 +1,6 @@ +error.user.exists=User exist with current email. +error.name.length=Value mast be length 2 and max 64 symbols. +error.not.email=Not email format +error.not.blank=Must not be empty + +success.user.register=User was register \ No newline at end of file diff --git a/src/test/java/ru/soune/no_copy/NoCopyApplicationTests.java b/src/test/java/ru/soune/no_copy/NoCopyApplicationTests.java deleted file mode 100644 index 0fe0bc6..0000000 --- a/src/test/java/ru/soune/no_copy/NoCopyApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package ru.soune.no_copy; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class NoCopyApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/ru/soune/no_copy/service/AuthServiceTest.java b/src/test/java/ru/soune/no_copy/service/AuthServiceTest.java new file mode 100644 index 0000000..6e4c91a --- /dev/null +++ b/src/test/java/ru/soune/no_copy/service/AuthServiceTest.java @@ -0,0 +1,142 @@ +package ru.soune.no_copy.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.context.MessageSource; +import org.springframework.security.crypto.password.PasswordEncoder; +import ru.soune.no_copy.dto.LoginRequest; +import ru.soune.no_copy.dto.RegisterRequest; +import ru.soune.no_copy.entity.AuthToken; +import ru.soune.no_copy.entity.User; +import ru.soune.no_copy.exception.NotValidationPasswordException; +import ru.soune.no_copy.exception.UserAlreadyExistsException; +import ru.soune.no_copy.exception.UserNotFoundException; +import ru.soune.no_copy.repository.AuthTokenRepository; +import ru.soune.no_copy.repository.UserRepository; + +import java.util.Locale; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class AuthServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private AuthTokenRepository authTokenRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private MessageSource messageSource; + + @InjectMocks + private AuthService authService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void registerSuccess() { + RegisterRequest request = new RegisterRequest("John","A.","Doe", + "john@example.com", "password123"); + + when(userRepository.existsByEmail(request.email())).thenReturn(false); + when(passwordEncoder.encode(request.password())).thenReturn("hashed"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(authTokenRepository.save(any(AuthToken.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + AuthToken token = authService.register(request); + User user = token.getUser(); + String email = user.getEmail(); + + assertNotNull(token); + assertNotNull(token.getToken()); + assertNotNull(token.getUser()); + assertEquals("john@example.com", email); + assertNotNull(token.getExpiresAt()); + } + + @Test + void registerExistingEmailThrows() { + RegisterRequest request = new RegisterRequest("John","A.","Doe", + "john@example.com", "password123"); + + when(userRepository.existsByEmail(request.email())) + .thenReturn(true); + when(messageSource.getMessage(anyString(), any(), any(Locale.class))) + .thenReturn("User exists"); + + UserAlreadyExistsException ex = assertThrows(UserAlreadyExistsException.class, + () -> authService.register(request)); + + assertEquals("User exists", ex.getMessage()); + } + + @Test + void login_success() { + LoginRequest request = new LoginRequest("test@mail.com", "password"); + + User user = new User(); + user.setUserId(1L); + user.setEmail("test@mail.com"); + user.setPassword("encoded_pass"); + + when(userRepository.findByEmail("test@mail.com")) + .thenReturn(Optional.of(user)); + when(passwordEncoder.matches("password", "encoded_pass")) + .thenReturn(true); + when(authTokenRepository.save(any(AuthToken.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + AuthToken token = authService.login(request); + + assertNotNull(token); + assertNotNull(token.getToken()); + assertEquals(user, token.getUser()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuthToken.class); + verify(authTokenRepository).save(captor.capture()); + + assertEquals(user, captor.getValue().getUser()); + assertNotNull(captor.getValue().getToken()); + } + + + @Test + void login_userNotFound() { + LoginRequest request = new LoginRequest("missing@mail.com", "pwd"); + + when(userRepository.findByEmail("missing@mail.com")).thenReturn(Optional.empty()); + + assertThrows(UserNotFoundException.class, () -> authService.login(request)); + } + + + @Test + void login_invalidPassword() { + LoginRequest request = new LoginRequest("test@mail.com", "wrong"); + + User user = new User(); + user.setEmail("test@mail.com"); + user.setPassword("encoded_pass"); + + when(userRepository.findByEmail("test@mail.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrong", "encoded_pass")).thenReturn(false); + + assertThrows(NotValidationPasswordException.class, () -> authService.login(request)); + } +} diff --git a/wait-for-it.sh b/wait-for-it.sh new file mode 100644 index 0000000..d990e0d --- /dev/null +++ b/wait-for-it.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi