diff --git a/pom.xml b/pom.xml index 62d686a..8d515db 100644 --- a/pom.xml +++ b/pom.xml @@ -128,11 +128,6 @@ postgresql runtime - - org.projectlombok - lombok - true - org.mapstruct mapstruct @@ -204,10 +199,6 @@ org.springframework.boot spring-boot-configuration-processor - - org.projectlombok - lombok - org.mapstruct mapstruct-processor @@ -255,14 +246,6 @@ org.springframework.boot spring-boot-maven-plugin - - - - org.projectlombok - lombok - - - diff --git a/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java b/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java index 5796f3b..76b6b65 100644 --- a/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java +++ b/src/main/java/org/openpodcastapi/opa/OpenPodcastAPI.java @@ -4,6 +4,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; +/// Main application @SpringBootApplication @EnableScheduling public class OpenPodcastAPI { diff --git a/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java b/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java index 64d1b8d..0482bde 100644 --- a/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java +++ b/src/main/java/org/openpodcastapi/opa/advice/GlobalExceptionHandler.java @@ -1,20 +1,19 @@ package org.openpodcastapi.opa.advice; import jakarta.persistence.EntityNotFoundException; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; +import org.jspecify.annotations.NonNull; import org.openpodcastapi.opa.exceptions.ValidationErrorResponse; +import org.slf4j.Logger; import org.springframework.dao.DataIntegrityViolationException; 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 java.time.Instant; -import java.util.List; + +import static org.slf4j.LoggerFactory.getLogger; /// A global handler for common exceptions thrown by the application. /// @@ -22,51 +21,49 @@ /// However, for common exceptions such as invalid parameters and /// not found entities, a global exception handler can be added. @RestControllerAdvice -@RequiredArgsConstructor -@Log4j2 public class GlobalExceptionHandler { + + private static final Logger log = getLogger(GlobalExceptionHandler.class); + /// Returns a 404 if a database entity is not found /// - /// @param exception the thrown [EntityNotFoundException] - /// @return a [ResponseEntity] containing the error message + /// @param exception the thrown exception + /// @return a response containing the error message @ExceptionHandler(EntityNotFoundException.class) - @ResponseStatus(HttpStatus.NOT_FOUND) public ResponseEntity<@NonNull String> handleEntityNotFoundException(EntityNotFoundException exception) { - log.debug("{}", exception.getMessage()); + log.info("{}", exception.getMessage()); return ResponseEntity.notFound().build(); } /// Returns a 400 error when conflicting data is entered /// - /// @param exception the thrown [DataIntegrityViolationException] - /// @return a [ResponseEntity] containing the error message + /// @param exception the thrown exception + /// @return a response containing the error message @ExceptionHandler(DataIntegrityViolationException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) public ResponseEntity<@NonNull String> handleDataIntegrityViolationException(DataIntegrityViolationException exception) { return ResponseEntity.badRequest().body(exception.getMessage()); } /// Returns a 400 error when illegal arguments are passed /// - /// @param exception the thrown [IllegalArgumentException] - /// @return a [ResponseEntity] containing the error message + /// @param exception the thrown exception + /// @return a response containing the error message @ExceptionHandler(IllegalArgumentException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) public ResponseEntity<@NonNull String> handleIllegalArgumentException(IllegalArgumentException exception) { return ResponseEntity.badRequest().body(exception.getMessage()); } /// Returns a 400 error when invalid arguments are passed to an endpoint /// - /// @param exception the thrown [MethodArgumentNotValidException] - /// @return a [ResponseEntity] containing the error message + /// @param exception the thrown exception + /// @return a response containing the error message @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<@NonNull ValidationErrorResponse> handleValidationException(MethodArgumentNotValidException exception) { - List errors = exception.getBindingResult().getFieldErrors().stream() + final var errors = exception.getBindingResult().getFieldErrors().stream() .map(fe -> new ValidationErrorResponse.FieldError(fe.getField(), fe.getDefaultMessage())) .toList(); - var body = new ValidationErrorResponse( + final var body = new ValidationErrorResponse( Instant.now(), HttpStatus.BAD_REQUEST.value(), errors diff --git a/src/main/java/org/openpodcastapi/opa/advice/GlobalModelAttributeAdvice.java b/src/main/java/org/openpodcastapi/opa/advice/GlobalModelAttributeAdvice.java index 221b670..fdfdc50 100644 --- a/src/main/java/org/openpodcastapi/opa/advice/GlobalModelAttributeAdvice.java +++ b/src/main/java/org/openpodcastapi/opa/advice/GlobalModelAttributeAdvice.java @@ -1,6 +1,6 @@ package org.openpodcastapi.opa.advice; -import lombok.extern.log4j.Log4j2; +import org.slf4j.Logger; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.ui.Model; @@ -9,34 +9,40 @@ import java.security.Principal; +import static org.slf4j.LoggerFactory.getLogger; + /// A helper class for adding user information to requests. /// /// This class is used to populate user details in templates /// and to ensure that a user is authenticated when viewing /// web pages. -@Log4j2 @ControllerAdvice public class GlobalModelAttributeAdvice { + private static final Logger log = getLogger(GlobalModelAttributeAdvice.class); + /// Adds a boolean `isAuthenticated` property to the request model based on /// whether the user is logged-in. /// - /// @param model the [Model] attached to the request + /// @param model the variables attached to the request @ModelAttribute public void addAuthenticationFlag(Model model) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - var isAuthenticated = authentication != null && authentication.isAuthenticated() + final var isAuthenticated = authentication != null && authentication.isAuthenticated() && !"anonymousUser".equals(authentication.getPrincipal()); + assert authentication != null; + log.debug("Authentication flag for {} added", authentication.getPrincipal()); model.addAttribute("isAuthenticated", isAuthenticated); } /// Adds user details to the request model. /// - /// @param principal the [Principal] representing the user - /// @param model the [Model] attached to the request + /// @param principal the principal representing the user + /// @param model the variables attached to the request @ModelAttribute public void addUserDetails(Principal principal, Model model) { - var username = principal != null ? principal.getName() : "Guest"; + final var username = principal != null ? principal.getName() : "Guest"; + log.debug("User details for {} added to model", username); model.addAttribute("username", username); } } \ No newline at end of file diff --git a/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java b/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java index e30e5b1..92cfdb3 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java +++ b/src/main/java/org/openpodcastapi/opa/auth/ApiBearerTokenAuthenticationConverter.java @@ -1,11 +1,14 @@ package org.openpodcastapi.opa.auth; import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.stereotype.Component; +import static org.slf4j.LoggerFactory.getLogger; + /// A converter that handles JWT-based auth for API requests. /// /// This converter targets only the API endpoints at `/api`. @@ -13,6 +16,8 @@ @Component public class ApiBearerTokenAuthenticationConverter implements AuthenticationConverter { + private static final Logger log = getLogger(ApiBearerTokenAuthenticationConverter.class); + private final BearerTokenAuthenticationConverter delegate = new BearerTokenAuthenticationConverter(); @@ -23,15 +28,18 @@ public Authentication convert(HttpServletRequest request) { // Don't authenticate the auth endpoints if (path.startsWith("/api/auth/")) { + log.debug("Bypassing token check for auth endpoint"); return null; } // If the request has no Bearer token, return null final var header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { + log.debug("Request with no auth header sent to {}", request.getRequestURI()); return null; } + log.debug("Converting request"); // Task Spring Boot with handling the request return delegate.convert(request); } diff --git a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java index 5a5b3b4..759864d 100644 --- a/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java +++ b/src/main/java/org/openpodcastapi/opa/auth/JwtAuthenticationProvider.java @@ -2,7 +2,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; -import lombok.NonNull; +import org.jspecify.annotations.NonNull; import org.openpodcastapi.opa.service.CustomUserDetails; import org.openpodcastapi.opa.user.UserRepository; import org.springframework.beans.factory.annotation.Value; @@ -28,7 +28,7 @@ public class JwtAuthenticationProvider implements AuthenticationProvider { /// Constructor with secret value provided in `.env` file /// or environment variables. /// - /// @param repository the [UserRepository] interface for user entities + /// @param repository the repository interface for user entities /// @param secret the secret value used to generate JWT values public JwtAuthenticationProvider( UserRepository repository, diff --git a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java index 32a6ce8..cc89dca 100644 --- a/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java +++ b/src/main/java/org/openpodcastapi/opa/config/SecurityConfig.java @@ -1,6 +1,5 @@ package org.openpodcastapi.opa.config; -import lombok.RequiredArgsConstructor; import org.openpodcastapi.opa.auth.ApiBearerTokenAuthenticationConverter; import org.openpodcastapi.opa.auth.JwtAuthenticationProvider; import org.springframework.context.annotation.Bean; @@ -25,7 +24,6 @@ /// Security configuration for the Spring application @Configuration @EnableWebSecurity -@RequiredArgsConstructor @EnableMethodSecurity public class SecurityConfig { @@ -44,12 +42,12 @@ public class SecurityConfig { /// API-related security configuration /// - /// @param http the [HttpSecurity] object to be configured - /// @param jwtAuthenticationProvider the [JwtAuthenticationProvider] used to handle JWT auth + /// @param http the security object to be configured + /// @param jwtAuthenticationProvider the JWT provider used to handle JWT auth /// @param entryPoint the entrypoint that commences the JWT auth - /// @param deniedHandler the [AccessDeniedHandler] that handles auth failures - /// @param converter the [ApiBearerTokenAuthenticationConverter] that manages JWT validation - /// @return the configured [HttpSecurity] object + /// @param deniedHandler the handler that handles auth failures + /// @param converter the bearer token converter that manages JWT validation + /// @return the configured security object @Bean @Order(1) public SecurityFilterChain apiSecurity( @@ -60,9 +58,9 @@ public SecurityFilterChain apiSecurity( ApiBearerTokenAuthenticationConverter converter ) { - AuthenticationManager jwtManager = new ProviderManager(jwtAuthenticationProvider); + final var jwtManager = new ProviderManager(jwtAuthenticationProvider); - BearerTokenAuthenticationFilter bearerFilter = + final var bearerFilter = new BearerTokenAuthenticationFilter(jwtManager, converter); bearerFilter.setAuthenticationFailureHandler( @@ -90,8 +88,8 @@ public SecurityFilterChain apiSecurity( /// Web-related security configuration /// - /// @param http the [HttpSecurity] object to be configured - /// @return the configured [HttpSecurity] object + /// @param http the security object to be configured + /// @return the configured security object @Bean @Order(2) public SecurityFilterChain webSecurity(HttpSecurity http) { @@ -119,7 +117,7 @@ public SecurityFilterChain webSecurity(HttpSecurity http) { /// The default password encoder used for hashing and encoding user passwords and JWTs /// - /// @return a configured [BCryptPasswordEncoder] + /// @return a configured password encoder @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -127,21 +125,21 @@ public BCryptPasswordEncoder passwordEncoder() { /// An authentication provider for password-based authentication /// - /// @param userDetailsService the [UserDetailsService] for loading user data + /// @param userDetailsService the service for loading user data /// @param passwordEncoder the default password encoder - /// @return the configured [DaoAuthenticationProvider] + /// @return the configured authentication provider @Bean public DaoAuthenticationProvider daoAuthenticationProvider(UserDetailsService userDetailsService, BCryptPasswordEncoder passwordEncoder) { - DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + final var provider = new DaoAuthenticationProvider(userDetailsService); provider.setPasswordEncoder(passwordEncoder); return provider; } /// An authentication provider for JWT-based authentication /// - /// @param provider a configured [JwtAuthenticationProvider] - /// @return a configured [ProviderManager] that uses the JWT auth provider + /// @param provider a configured provider + /// @return a configured manager that uses the JWT auth provider /// @see JwtAuthenticationProvider for provider details @Bean(name = "jwtAuthManager") public AuthenticationManager jwtAuthenticationManager(JwtAuthenticationProvider provider) { @@ -150,8 +148,8 @@ public AuthenticationManager jwtAuthenticationManager(JwtAuthenticationProvider /// An authentication provider for API POST login /// - /// @param daoProvider a configured [DaoAuthenticationProvider] - /// @return a configured [ProviderManager] that uses basic username/password auth + /// @param daoProvider a configured auth provider + /// @return a configured manager that uses basic username/password auth @Bean(name = "apiLoginManager", defaultCandidate = false) public AuthenticationManager apiLoginAuthenticationManager( DaoAuthenticationProvider daoProvider) { diff --git a/src/main/java/org/openpodcastapi/opa/config/WebConfig.java b/src/main/java/org/openpodcastapi/opa/config/WebConfig.java index 1c41eef..604a96c 100644 --- a/src/main/java/org/openpodcastapi/opa/config/WebConfig.java +++ b/src/main/java/org/openpodcastapi/opa/config/WebConfig.java @@ -31,7 +31,7 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { /// /// See [Thymeleaf Layout Dialect](https://ultraq.github.io/thymeleaf-layout-dialect/) for more information /// - /// @return the configured [LayoutDialect] + /// @return the configured layout dialect @Bean public LayoutDialect layoutDialect() { return new LayoutDialect(); diff --git a/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java b/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java index daec4f1..8d31de3 100644 --- a/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/api/AuthController.java @@ -2,17 +2,14 @@ import jakarta.persistence.EntityNotFoundException; import jakarta.validation.constraints.NotNull; -import lombok.NonNull; -import lombok.extern.log4j.Log4j2; +import org.jspecify.annotations.NonNull; import org.openpodcastapi.opa.auth.AuthDTO; import org.openpodcastapi.opa.security.TokenService; -import org.openpodcastapi.opa.user.UserEntity; import org.openpodcastapi.opa.user.UserRepository; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -20,7 +17,6 @@ /// Controllers for API-based authentication @RestController -@Log4j2 public class AuthController { private final TokenService tokenService; private final UserRepository userRepository; @@ -44,12 +40,12 @@ public AuthController( /// The API login endpoint. Accepts a basic username/password combination to authenticate. /// - /// @param loginRequest the [AuthDTO.LoginRequest] containing the user's credentials - /// @return a [ResponseEntity] containing a [AuthDTO.LoginSuccessResponse] + /// @param loginRequest the login request containing the user's credentials + /// @return a success response @PostMapping("/api/auth/login") public ResponseEntity login(@RequestBody @NotNull AuthDTO.LoginRequest loginRequest) { // Set the authentication using the provided details - Authentication authentication = authenticationManager.authenticate( + final var authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password()) ); @@ -71,17 +67,17 @@ public AuthController( /// The token refresh endpoint. Validates refresh tokens and returns new access tokens. /// - /// @param refreshTokenRequest the [AuthDTO.RefreshTokenRequest] request body - /// @return a [ResponseEntity] containing a [AuthDTO.RefreshTokenResponse] + /// @param refreshTokenRequest the refresh token request body + /// @return a token refresh response @PostMapping("/api/auth/refresh") public ResponseEntity getRefreshToken(@RequestBody @NotNull AuthDTO.RefreshTokenRequest refreshTokenRequest) { final var targetUserEntity = userRepository.findUserByUsername(refreshTokenRequest.username()).orElseThrow(() -> new EntityNotFoundException("No user with username " + refreshTokenRequest.username() + " found")); // Validate the existing refresh token - final UserEntity userEntity = tokenService.validateRefreshToken(refreshTokenRequest.refreshToken(), targetUserEntity); + final var userEntity = tokenService.validateRefreshToken(refreshTokenRequest.refreshToken(), targetUserEntity); // Generate new access token - final String newAccessToken = tokenService.generateAccessToken(userEntity); + final var newAccessToken = tokenService.generateAccessToken(userEntity); // Format the token and expiration time into a DTO final var response = new AuthDTO.RefreshTokenResponse(newAccessToken, String.valueOf(tokenService.getExpirationTime())); diff --git a/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java index ea35256..5a7edf4 100644 --- a/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/web/DocsController.java @@ -1,12 +1,10 @@ package org.openpodcastapi.opa.controllers.web; -import lombok.extern.log4j.Log4j2; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; /// Controller for the hosted documentation endpoints @Controller -@Log4j2 public class DocsController { /// The hosted documentation endpoint. Redirects users to the index page. diff --git a/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java index 3580fee..1524957 100644 --- a/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/web/HomeController.java @@ -1,15 +1,11 @@ package org.openpodcastapi.opa.controllers.web; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; /// Controller for the home and landing page controllers @Controller -@RequiredArgsConstructor -@Log4j2 public class HomeController { /// Controller for the landing page. @@ -23,7 +19,7 @@ public String getLandingPage() { /// Controller for an authenticated user's homepage. /// Redirects users to the login page if they're not authenticated. /// - /// @param auth the [Authentication] object for the user + /// @param auth the authentication object for the user /// @return the home page @GetMapping("/home") public String getHomePage(Authentication auth) { diff --git a/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java b/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java index 1a2322e..acc7472 100644 --- a/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java +++ b/src/main/java/org/openpodcastapi/opa/controllers/web/WebAuthController.java @@ -1,8 +1,6 @@ package org.openpodcastapi.opa.controllers.web; import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.user.UserDTO; import org.openpodcastapi.opa.user.UserService; import org.springframework.dao.DataIntegrityViolationException; @@ -16,13 +14,18 @@ /// Controller for the web authentication endpoints @Controller -@Log4j2 -@RequiredArgsConstructor public class WebAuthController { private static final String USER_REQUEST_ATTRIBUTE = "createUserRequest"; private static final String REGISTER_TEMPLATE = "auth/register"; private final UserService userService; + /// Constructor for the web auth controller + /// + /// @param userService the [UserService] class to handle user interactions + public WebAuthController(UserService userService) { + this.userService = userService; + } + /// Controller for the login page. /// Displays an error message if a previous login was unsuccessful. /// @@ -60,8 +63,8 @@ public String getRegister(Model model) { /// Controller for the account registration form. /// /// @param createUserRequest the [UserDTO.CreateUserDTO] containing the new account details - /// @param result the [BindingResult] for displaying data validation errors - /// @param model a placeholder for additional data to be passed to Thymeleaf + /// @param result the [BindingResult] for displaying data validation errors + /// @param model a placeholder for additional data to be passed to Thymeleaf /// @return a redirect to the login page, if successful @PostMapping("/register") public String processRegistration( diff --git a/src/main/java/org/openpodcastapi/opa/exceptions/ValidationErrorResponse.java b/src/main/java/org/openpodcastapi/opa/exceptions/ValidationErrorResponse.java index 96d192c..0b5f3fb 100644 --- a/src/main/java/org/openpodcastapi/opa/exceptions/ValidationErrorResponse.java +++ b/src/main/java/org/openpodcastapi/opa/exceptions/ValidationErrorResponse.java @@ -7,7 +7,7 @@ /// /// @param timestamp the timestamp at which the error occurred /// @param status the HTTP status code -/// @param errors a list of [FieldError] objects +/// @param errors a list of field errors public record ValidationErrorResponse( Instant timestamp, int status, diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java index c0fc33e..51d7827 100644 --- a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java +++ b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenEntity.java @@ -1,23 +1,16 @@ package org.openpodcastapi.opa.security; import jakarta.persistence.*; -import lombok.*; import org.openpodcastapi.opa.user.UserEntity; import java.time.Instant; /// Entity for refresh tokens @Entity -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder @Table(name = "refresh_tokens") public class RefreshTokenEntity { /// The token ID @Id - @Generated @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -27,6 +20,7 @@ public class RefreshTokenEntity { /// The user that owns the token @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") private UserEntity user; /// The date at which the token expires @@ -37,6 +31,77 @@ public class RefreshTokenEntity { @Column(nullable = false) private Instant createdAt; + /// No-args constructor + public RefreshTokenEntity() { + } + + /// Required-args constructor + /// + /// @param tokenHash the hash of the token + /// @param user the user associated with the token + /// @param expiresAt the expiry date of the token + public RefreshTokenEntity(String tokenHash, UserEntity user, Instant expiresAt) { + this.tokenHash = tokenHash; + this.user = user; + this.expiresAt = expiresAt; + } + + /// Retrieves the ID of the refresh token entity + /// + /// @return the ID of the entity + public Long getId() { + return id; + } + + /// Retrieves the token hash for a token + /// + /// @return the token hash + public String getTokenHash() { + return tokenHash; + } + + /// Retrieves the user associated with a refresh token + /// + /// @return the user associated with the token + public UserEntity getUser() { + return user; + } + + /// Assigns a user to a refresh token + /// + /// @param user the user associated with the token + public void setUser(UserEntity user) { + this.user = user; + } + + /// Returns the expiry date of a token + /// + /// @return the expiry date for the token + public Instant getExpiresAt() { + return expiresAt; + } + + /// Sets the expiry date for a token + /// + /// @param expiresAt the expiry date of the token + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + /// Retrieves the creation date for a token + /// + /// @return the creation date of the token + public Instant getCreatedAt() { + return createdAt; + } + + /// Sets the created date for a token + /// + /// @param createdAt the created date of the token + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + /// Performs actions on initial save @PrePersist public void prePersist() { diff --git a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java index 11d1d96..0decacd 100644 --- a/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java +++ b/src/main/java/org/openpodcastapi/opa/security/RefreshTokenRepository.java @@ -1,6 +1,6 @@ package org.openpodcastapi.opa.security; -import lombok.NonNull; +import org.jspecify.annotations.NonNull; import org.openpodcastapi.opa.user.UserEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/src/main/java/org/openpodcastapi/opa/security/TokenService.java b/src/main/java/org/openpodcastapi/opa/security/TokenService.java index f70623b..9b3c3bf 100644 --- a/src/main/java/org/openpodcastapi/opa/security/TokenService.java +++ b/src/main/java/org/openpodcastapi/opa/security/TokenService.java @@ -2,7 +2,6 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; -import lombok.RequiredArgsConstructor; import org.openpodcastapi.opa.user.UserEntity; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -16,32 +15,52 @@ /// Service for refresh token and JWT-related actions @Service -@RequiredArgsConstructor public class TokenService { private final RefreshTokenRepository repository; private final BCryptPasswordEncoder passwordEncoder; - // The secret string used to generate secret keys @Value("${jwt.secret}") private String secret; - // The TTL for each JWT, in minutes @Value("${jwt.expiration-minutes:15}") private long accessTokenMinutes; - // The TTL for each refresh token, in days @Value("${jwt.refresh-days:7}") private long refreshTokenDays; - @Value("${jwt.ttl}") private String jwtExpiration; - // The calculated secret key + /// Required args constructor + /// + /// @param repository the refresh token repository for token interaction + /// @param passwordEncoder the password encoder for encoding tokens + public TokenService(RefreshTokenRepository repository, BCryptPasswordEncoder passwordEncoder) { + this.repository = repository; + this.passwordEncoder = passwordEncoder; + } + + /// The calculated secret key private SecretKey key() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } + /// Calculates the token expiry date from a given timestamp + /// + /// @param fromDate the date from which to calculate the expiry + /// @return a formatted expiry date + private Date calculateAccessTokenExpiryDate(Instant fromDate) { + return Date.from(fromDate.plusSeconds(accessTokenMinutes * 60)); + } + + /// Calculates the refresh token expiry time from a given timestamp + /// + /// @param fromDate the date from which to calculate the expiry + /// @return the time to expiry in seconds + private Instant calculateRefreshTokenExpiry(Instant fromDate) { + return fromDate.plusSeconds(refreshTokenDays * 24 * 3600); + } + /// Returns the expiration time for JWTs /// /// @return a number representing the user-defined TTL of JWT tokens @@ -51,33 +70,29 @@ public long getExpirationTime() { /// Generates an access token for a given user /// - /// @param userEntity the [UserEntity] to generate a token for + /// @param userEntity the user to generate a token for /// @return the generated token public String generateAccessToken(UserEntity userEntity) { - Instant now = Instant.now(); + final var now = Instant.now(); return Jwts.builder() .subject(userEntity.getUuid().toString()) .claim("username", userEntity.getUsername()) .issuedAt(Date.from(now)) - .expiration(Date.from(now.plusSeconds(accessTokenMinutes * 60))) + .expiration(calculateAccessTokenExpiryDate(Instant.now())) .signWith(key()) .compact(); } /// Generates a refresh token for a given user /// - /// @param userEntity the [UserEntity] to generate a refresh token for + /// @param userEntity the user to generate a refresh token for /// @return the generated refresh token public String generateRefreshToken(UserEntity userEntity) { - String raw = UUID.randomUUID().toString() + UUID.randomUUID(); - String hash = passwordEncoder.encode(raw); + final var raw = UUID.randomUUID().toString() + UUID.randomUUID(); + final var hash = passwordEncoder.encode(raw); + final var expiryDate = calculateRefreshTokenExpiry(Instant.now()); - RefreshTokenEntity token = RefreshTokenEntity.builder() - .tokenHash(hash) - .user(userEntity) - .createdAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(refreshTokenDays * 24 * 3600)) - .build(); + final var token = new RefreshTokenEntity(hash, userEntity, expiryDate); repository.save(token); return raw; @@ -86,8 +101,8 @@ public String generateRefreshToken(UserEntity userEntity) { /// Validates the refresh token for a user and updates its expiry time /// /// @param rawToken the raw token to validate - /// @param userEntity the [UserEntity] to validate the token for - /// @return the validated [UserEntity] + /// @param userEntity the user to validate the token for + /// @return the validated user public UserEntity validateRefreshToken(String rawToken, UserEntity userEntity) { // Only fetch refresh tokens for the requesting user for (RefreshTokenEntity token : repository.findAllByUser(userEntity)) { @@ -95,8 +110,8 @@ public UserEntity validateRefreshToken(String rawToken, UserEntity userEntity) { if (passwordEncoder.matches(rawToken, token.getTokenHash()) && token.getExpiresAt().isAfter(Instant.now())) { // Update the expiry date on the refresh token - token.setExpiresAt(Instant.now().plusSeconds(refreshTokenDays * 24 * 3600)); - final RefreshTokenEntity updatedToken = repository.save(token); + token.setExpiresAt(calculateRefreshTokenExpiry(Instant.now())); + final var updatedToken = repository.save(token); // Return the user to confirm the token is valid return updatedToken.getUser(); diff --git a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java index e0e75b7..8d6fdc5 100644 --- a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java +++ b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetails.java @@ -1,6 +1,6 @@ package org.openpodcastapi.opa.service; -import lombok.NonNull; +import org.jspecify.annotations.NonNull; import org.openpodcastapi.opa.user.UserRoles; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; diff --git a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java index 61947dd..442656c 100644 --- a/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java +++ b/src/main/java/org/openpodcastapi/opa/service/CustomUserDetailsService.java @@ -1,7 +1,6 @@ package org.openpodcastapi.opa.service; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.NonNull; import org.openpodcastapi.opa.user.UserEntity; import org.openpodcastapi.opa.user.UserRepository; import org.springframework.security.core.userdetails.UserDetails; @@ -9,12 +8,21 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; +import java.util.Set; +import java.util.stream.Collectors; + /// Custom service for mapping user details @Service -@RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; + /// Required-args constructor + /// + /// @param userRepository the user repository for user interactions + public CustomUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + /// Returns a mapped custom user details model by username /// /// @param username the username to map @@ -28,14 +36,17 @@ public class CustomUserDetailsService implements UserDetailsService { /// Maps a user to a custom user details model /// - /// @param userEntity the [UserEntity] model to map + /// @param userEntity the user model to map private CustomUserDetails mapToUserDetails(UserEntity userEntity) { return new CustomUserDetails( userEntity.getId(), userEntity.getUuid(), userEntity.getUsername(), userEntity.getPassword(), - userEntity.getUserRoles() + userEntity.getUserRoles() == null + ? Set.of() + : userEntity.getUserRoles().stream() + .collect(Collectors.toUnmodifiableSet()) ); } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java index 18162f7..76aa9c1 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java @@ -1,11 +1,12 @@ package org.openpodcastapi.opa.subscription; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; -import lombok.NonNull; import org.hibernate.validator.constraints.URL; import org.hibernate.validator.constraints.UUID; +import org.jspecify.annotations.NonNull; import org.springframework.data.domain.Page; import java.time.Instant; @@ -30,6 +31,7 @@ public record SubscriptionCreateDTO( /// @param createdAt the date at which the subscription link was created /// @param updatedAt the date at which the subscription link was last updated /// @param unsubscribedAt the date at which the user unsubscribed from the feed + @JsonInclude(JsonInclude.Include.NON_NULL) public record UserSubscriptionDTO( @JsonProperty(required = true) @UUID java.util.UUID uuid, @JsonProperty(required = true) @URL String feedUrl, @@ -63,7 +65,7 @@ public record SubscriptionFailureDTO( /// A paginated DTO representing a list of subscriptions /// - /// @param subscriptions the [UserSubscriptionDTO] list representing the subscriptions + /// @param subscriptions the DTO list representing the subscriptions /// @param first whether this is the first page /// @param last whether this is the last page /// @param page the current page number @@ -81,10 +83,10 @@ public record SubscriptionPageDTO( int numberOfElements, int size ) { - /// Returns a paginated response with details from a [Page] of user subscriptions + /// Returns a paginated response with details from a page of user subscriptions /// - /// @param page the [Page] of [UserSubscriptionDTO] items - /// @return a [SubscriptionPageDTO] with pagination details filled out + /// @param page the paginated list of DTO items + /// @return a subscription DTO with pagination details filled out public static SubscriptionPageDTO fromPage(Page<@NonNull UserSubscriptionDTO> page) { return new SubscriptionPageDTO( page.getContent(), diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java index e12b7ac..efa6b32 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionEntity.java @@ -1,7 +1,6 @@ package org.openpodcastapi.opa.subscription; import jakarta.persistence.*; -import lombok.*; import java.time.Instant; import java.util.Set; @@ -9,17 +8,11 @@ /// An entity representing a subscription wrapper @Entity -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Getter -@Setter @Table(name = "subscriptions") public class SubscriptionEntity { /// The subscription ID @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Generated private Long id; /// The UUID of the subscription. @@ -31,8 +24,8 @@ public class SubscriptionEntity { @Column(nullable = false) private String feedUrl; - /// A list of [UserSubscriptionEntity] associated with the subscription - @OneToMany(mappedBy = "subscription") + /// A list of user subscriptions associated with the subscription + @OneToMany(mappedBy = "subscription", cascade = CascadeType.REMOVE) private Set subscribers; /// The date at which the subscription was created @@ -43,6 +36,19 @@ public class SubscriptionEntity { @Column(nullable = false) private Instant updatedAt; + /// No-args constructor + public SubscriptionEntity() { + } + + /// Required-args constructor + /// + /// @param uuid the UUID of the subscription + /// @param feedUrl the feed URL of the subscription + public SubscriptionEntity(UUID uuid, String feedUrl) { + this.uuid = uuid; + this.feedUrl = feedUrl; + } + /// Performs actions when the entity is initially saved @PrePersist public void prePersist() { @@ -58,4 +64,67 @@ public void preUpdate() { // Store the timestamp of the update this.setUpdatedAt(Instant.now()); } + + /// Retrieves the ID of a subscription + /// + /// @return the ID of the subscription + public Long getId() { + return this.id; + } + + /// Retrieves the UUID of a subscription + /// + /// @return the UUID of the subscription + public UUID getUuid() { + return this.uuid; + } + + /// Sets the UUID of a subscription + /// + /// @param uuid the UUID of the subscription + public void setUuid(UUID uuid) { + this.uuid = uuid; + } + + /// Retrieves the feed URL of a subscription + /// + /// @return the feed URL associated with a subscription + public String getFeedUrl() { + return this.feedUrl; + } + + /// Sets the feed URL of a subscription + /// + /// @param feedUrl the feed URL + public void setFeedUrl(String feedUrl) { + this.feedUrl = feedUrl; + } + + /// Retrieves the creation date of a subscription + /// + /// @return the creation date of the subscription + public Instant getCreatedAt() { + return this.createdAt; + } + + /// Sets the creation date of a subscription + /// + /// @param createdAt the creation date of the subscription + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + /// Retrieves the last update time of a subscription + /// + /// @return the date at which the subscription was last updated + public Instant getUpdatedAt() { + return this.updatedAt; + } + + /// Sets the last update time of a subscription + /// + /// @param updatedAt the date at which the subscription was last updated + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java index 87c19e5..498c8fc 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionMapper.java @@ -8,13 +8,12 @@ /// Mapper for subscription items @Mapper(componentModel = "spring") public interface SubscriptionMapper { - /// Maps a [SubscriptionDTO.SubscriptionCreateDTO] to a [SubscriptionEntity] + /// Maps a DTO to a subscription entity /// - /// @param dto the [SubscriptionDTO.SubscriptionCreateDTO] to map - /// @return a mapped [SubscriptionEntity] - @Mapping(target = "id", ignore = true) + /// @param dto the DTO to map + /// @return a mapped subscription entity @Mapping(target = "uuid", source = "uuid") - @Mapping(target = "subscribers", ignore = true) + @Mapping(target = "feedUrl", source = "feedUrl") @Mapping(target = "createdAt", ignore = true) @Mapping(target = "updatedAt", ignore = true) SubscriptionEntity toEntity(SubscriptionDTO.SubscriptionCreateDTO dto); @@ -22,7 +21,7 @@ public interface SubscriptionMapper { /// Maps a string UUID to a UUID instance /// /// @param feedUUID the string UUID to map - /// @return the mapped [UUID] instance + /// @return the mapped UUID instance default UUID mapStringToUUID(String feedUUID) { return UUID.fromString(feedUUID); } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java index 79f7c99..78852d7 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRepository.java @@ -1,6 +1,6 @@ package org.openpodcastapi.opa.subscription; -import lombok.NonNull; +import org.jspecify.annotations.NonNull; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -12,7 +12,7 @@ public interface SubscriptionRepository extends JpaRepository<@NonNull SubscriptionEntity, @NonNull Long> { /// Finds a single subscription by UUID. Returns `null` if no value exists. /// - /// @param uuid the [UUID] to match - /// @return the matching [SubscriptionEntity] + /// @param uuid the UUID to match + /// @return the matching subscription Optional findByUuid(UUID uuid); } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java index 4f397e3..c99d355 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionRestController.java @@ -1,10 +1,9 @@ package org.openpodcastapi.opa.subscription; import jakarta.persistence.EntityNotFoundException; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; +import org.jspecify.annotations.NonNull; import org.openpodcastapi.opa.service.CustomUserDetails; +import org.slf4j.Logger; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; @@ -16,26 +15,33 @@ import java.util.List; import java.util.UUID; +import static org.slf4j.LoggerFactory.getLogger; + /// Controller for API subscription requests @RestController -@RequiredArgsConstructor -@Log4j2 @RequestMapping("/api/v1/subscriptions") public class SubscriptionRestController { + private static final Logger log = getLogger(SubscriptionRestController.class); private final SubscriptionService service; + /// Required-args constructor + /// + /// @param service the service used for subscription actions + public SubscriptionRestController(SubscriptionService service) { + this.service = service; + } + /// Returns all subscriptions for a given user /// - /// @param user the [CustomUserDetails] of the authenticated user - /// @param pageable the [Pageable] pagination object + /// @param user the custom user details of the authenticated user + /// @param pageable the pagination options /// @param includeUnsubscribed whether to include unsubscribed feeds in the response - /// @return a [ResponseEntity] containing [SubscriptionDTO.SubscriptionPageDTO] objects + /// @return a response containing subscription objects @GetMapping - @ResponseStatus(HttpStatus.OK) @PreAuthorize("hasRole('USER')") public ResponseEntity getAllSubscriptionsForUser(@AuthenticationPrincipal CustomUserDetails user, Pageable pageable, @RequestParam(defaultValue = "false") boolean includeUnsubscribed) { log.info("{}", user.getAuthorities()); - Page dto; + final Page dto; if (includeUnsubscribed) { dto = service.getAllSubscriptionsForUser(user.id(), pageable); @@ -43,20 +49,17 @@ public class SubscriptionRestController { dto = service.getAllActiveSubscriptionsForUser(user.id(), pageable); } - log.debug("{}", dto); - return new ResponseEntity<>(SubscriptionDTO.SubscriptionPageDTO.fromPage(dto), HttpStatus.OK); } /// Returns a single subscription entry by UUID /// /// @param uuid the UUID value to query for - /// @param user the [CustomUserDetails] for the user - /// @return a [ResponseEntity] containing a [SubscriptionDTO.UserSubscriptionDTO] object + /// @param user the custom user details for the user + /// @return a response containing a subscription DTO /// @throws EntityNotFoundException if no entry is found /// @throws IllegalArgumentException if the UUID is improperly formatted @GetMapping("/{uuid}") - @ResponseStatus(HttpStatus.OK) @PreAuthorize("hasRole('USER')") public ResponseEntity getSubscriptionByUuid(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) throws EntityNotFoundException { // Attempt to validate the UUID value from the provided string @@ -66,19 +69,18 @@ public class SubscriptionRestController { // Fetch the subscription, throw an EntityNotFoundException if this fails final var dto = service.getUserSubscriptionBySubscriptionUuid(uuidValue, user.id()); - // Return the mapped subscriptionEntity entry + // Return the mapped subscription entry return new ResponseEntity<>(dto, HttpStatus.OK); } /// Updates the subscription status of a subscription for a given user /// /// @param uuid the UUID of the subscription to update - /// @param user the [CustomUserDetails] for the user - /// @return a [ResponseEntity] containing a [SubscriptionDTO.UserSubscriptionDTO] object + /// @param user the custom user details for the user + /// @return a reponse containing a subscription DTO /// @throws EntityNotFoundException if no entry is found /// @throws IllegalArgumentException if the UUID is improperly formatted @PostMapping("/{uuid}/unsubscribe") - @ResponseStatus(HttpStatus.OK) @PreAuthorize("hasRole('USER')") public ResponseEntity unsubscribeUserFromFeed(@PathVariable String uuid, @AuthenticationPrincipal CustomUserDetails user) { // Attempt to validate the UUID value from the provided string @@ -90,11 +92,11 @@ public class SubscriptionRestController { return new ResponseEntity<>(dto, HttpStatus.OK); } - /// Bulk creates [UserSubscriptionEntity] objects for a user. Creates new [SubscriptionEntity] objects if not already present + /// Bulk creates user subscriptions for a user. Creates new subscriptions if not already present /// - /// @param request a list of [SubscriptionDTO.SubscriptionCreateDTO] objects - /// @param user the [CustomUserDetails] for the user - /// @return a [ResponseEntity] containing a [SubscriptionDTO.BulkSubscriptionResponseDTO] object + /// @param request a list of subscription creation DTOs + /// @param user the custom user details for the user + /// @return a response containing a bulk subscription DTO @PostMapping @PreAuthorize("hasRole('USER')") public ResponseEntity createUserSubscriptions(@RequestBody List request, @AuthenticationPrincipal CustomUserDetails user) { diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java index ffedb36..5807355 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java @@ -1,10 +1,9 @@ package org.openpodcastapi.opa.subscription; import jakarta.persistence.EntityNotFoundException; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; +import org.jspecify.annotations.NonNull; import org.openpodcastapi.opa.user.UserRepository; +import org.slf4j.Logger; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -15,27 +14,43 @@ import java.util.List; import java.util.UUID; +import static org.slf4j.LoggerFactory.getLogger; + /// Service for subscription-related actions @Service -@RequiredArgsConstructor -@Log4j2 public class SubscriptionService { + private static final Logger log = getLogger(SubscriptionService.class); private final SubscriptionRepository subscriptionRepository; private final SubscriptionMapper subscriptionMapper; private final UserSubscriptionRepository userSubscriptionRepository; private final UserSubscriptionMapper userSubscriptionMapper; private final UserRepository userRepository; + /// All-args constructor + /// + /// @param subscriptionRepository the repository used for subscription interactions + /// @param subscriptionMapper the mapper used for mapping subscription entities and DTOs + /// @param userSubscriptionRepository the repository used for user subscription interactions + /// @param userSubscriptionMapper the mapper used for mapping user subscription entities and DTOs + /// @param userRepository the repository used for user interactions + public SubscriptionService(SubscriptionRepository subscriptionRepository, SubscriptionMapper subscriptionMapper, UserSubscriptionRepository userSubscriptionRepository, UserSubscriptionMapper userSubscriptionMapper, UserRepository userRepository) { + this.subscriptionRepository = subscriptionRepository; + this.subscriptionMapper = subscriptionMapper; + this.userSubscriptionRepository = userSubscriptionRepository; + this.userSubscriptionMapper = userSubscriptionMapper; + this.userRepository = userRepository; + } + /// Fetches an existing repository from the database or creates a new one if none is found /// - /// @param dto the [SubscriptionDTO.SubscriptionCreateDTO] containing the subscription data - /// @return the fetched or created [SubscriptionEntity] + /// @param dto the DTO containing the subscription data + /// @return the fetched or created subscription protected SubscriptionEntity fetchOrCreateSubscription(SubscriptionDTO.SubscriptionCreateDTO dto) { final var feedUuid = UUID.fromString(dto.uuid()); return subscriptionRepository .findByUuid(feedUuid) .orElseGet(() -> { - log.debug("Creating new subscription with UUID {}", dto.uuid()); + log.info("Creating new subscription with UUID {} and feed URL {}", dto.uuid(), dto.feedUrl()); return subscriptionRepository.save(subscriptionMapper.toEntity(dto)); }); } @@ -44,10 +59,10 @@ protected SubscriptionEntity fetchOrCreateSubscription(SubscriptionDTO.Subscript /// /// @param subscriptionUuid the UUID of the subscription /// @param userId the database ID of the user - /// @return a [SubscriptionDTO.UserSubscriptionDTO] of the user subscription + /// @return a DTO of the user subscription /// @throws EntityNotFoundException if no entry is found @Transactional(readOnly = true) - public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid(UUID subscriptionUuid, Long userId) { + public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid(UUID subscriptionUuid, Long userId) throws EntityNotFoundException { log.debug("Fetching subscription {} for userEntity {}", subscriptionUuid, userId); final var userSubscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, subscriptionUuid) .orElseThrow(() -> new EntityNotFoundException("subscription not found for userEntity")); @@ -59,8 +74,8 @@ public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid /// Gets all subscriptions for the authenticated userEntity /// /// @param userId the database ID of the authenticated userEntity - /// @param pageable the [Pageable] object containing pagination options - /// @return a paginated set of [SubscriptionDTO.UserSubscriptionDTO] objects + /// @param pageable the pagination options + /// @return a paginated set of user subscriptions @Transactional(readOnly = true) public Page getAllSubscriptionsForUser(Long userId, Pageable pageable) { log.debug("Fetching subscriptions for {}", userId); @@ -72,20 +87,23 @@ public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid /// Gets all active subscriptions for the authenticated user /// /// @param userId the database ID of the authenticated user - /// @param pageable the [Pageable] object containing pagination options - /// @return a paginated set of [SubscriptionDTO.UserSubscriptionDTO] objects + /// @param pageable the pagination options + /// @return a paginated set of user subscriptions @Transactional(readOnly = true) public Page getAllActiveSubscriptionsForUser(Long userId, Pageable pageable) { log.debug("Fetching all active subscriptions for {}", userId); - return userSubscriptionRepository.findAllByUserIdAndUnsubscribedAtNotEmpty(userId, pageable).map(userSubscriptionMapper::toDto); + log.info("{}", userId); + var thing = userSubscriptionRepository.findAll(); + thing.forEach(entity -> log.info("{}, {}", entity.getUser().getId(), entity.getUnsubscribedAt())); + return userSubscriptionRepository.findAllByUserIdAndUnsubscribedAtIsNull(userId, pageable).map(userSubscriptionMapper::toDto); } /// Persists a new user subscription to the database /// If an existing entry is found for the user and subscription, the `isSubscribed` property is set to `true` /// - /// @param subscriptionEntity the target [SubscriptionEntity] + /// @param subscriptionEntity the target subscription /// @param userId the ID of the target user - /// @return a [SubscriptionDTO.UserSubscriptionDTO] representation of the subscription link + /// @return a response containing a user subscription DTO /// @throws EntityNotFoundException if no matching user is found protected SubscriptionDTO.UserSubscriptionDTO persistUserSubscription(SubscriptionEntity subscriptionEntity, Long userId) { final var userEntity = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("user not found")); @@ -105,13 +123,13 @@ protected SubscriptionDTO.UserSubscriptionDTO persistUserSubscription(Subscripti return userSubscriptionMapper.toDto(userSubscriptionRepository.save(newSubscription)); } - /// Creates [UserSubscriptionEntity] links in bulk. If the [SubscriptionEntity] isn't already in the system, this is added before the user is subscribed. + /// Creates user subscriptions in bulk. If the subscription isn't already in the system, this is added before the user is subscribed. /// - /// @param requests a list of [SubscriptionDTO.SubscriptionCreateDTO] objects to create + /// @param requests a list of subscriptions to create /// @param userId the ID of the requesting user - /// @return a [SubscriptionDTO.BulkSubscriptionResponseDTO] DTO containing a list of successes and failures + /// @return a response containing a bulk creation DTO @Transactional - public SubscriptionDTO.BulkSubscriptionResponseDTO addSubscriptions(List requests, Long userId) { + public SubscriptionDTO.@NonNull BulkSubscriptionResponseDTO addSubscriptions(List requests, Long userId) { List successes = new ArrayList<>(); List failures = new ArrayList<>(); @@ -121,7 +139,6 @@ public SubscriptionDTO.BulkSubscriptionResponseDTO addSubscriptions(List { - /// Finds an individual [UserSubscriptionEntity] by user ID and feed UUID. + /// Finds an individual user subscription by user ID and feed UUID. /// Returns `null` if no matching value is found. /// /// @param userId the ID of the user /// @param subscriptionUuid the UUID of the subscription - /// @return a [UserSubscriptionEntity], if one matches + /// @return a user subscription, if one matches Optional findByUserIdAndSubscriptionUuid(Long userId, UUID subscriptionUuid); - /// Returns a paginated list of [UserSubscriptionEntity] objects associated with a user. + /// Returns a paginated list of user subscription objects associated with a user. /// /// @param userId the ID of the associated user - /// @param pageable the [Pageable] object containing pagination information - /// @return a [Page] of [UserSubscriptionEntity] associated with the user + /// @param pageable the pagination options + /// @return a paginated list of user subscription associated with the user Page<@NonNull UserSubscriptionEntity> findAllByUserId(Long userId, Pageable pageable); - /// Returns a paginated list of [UserSubscriptionEntity] for a user where the [UserSubscriptionEntity#unsubscribedAt] - /// field is not empty. + /// Returns a paginated list of active user subscriptions for a user. /// /// @param userId the ID of the associated user - /// @param pageable the [Pageable] object containing pagination information - /// @return a [Page] of [UserSubscriptionEntity] associated with the user - Page<@NonNull UserSubscriptionEntity> findAllByUserIdAndUnsubscribedAtNotEmpty(Long userId, Pageable pageable); + /// @param pageable the pagination options + /// @return a paginated list of user subscription associated with the user + Page<@NonNull UserSubscriptionEntity> findAllByUserIdAndUnsubscribedAtIsNull(Long userId, Pageable pageable); } diff --git a/src/main/java/org/openpodcastapi/opa/user/UserDTO.java b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java index 5ad824f..54658e5 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserDTO.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserDTO.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotNull; -import lombok.NonNull; +import org.jspecify.annotations.NonNull; import org.springframework.data.domain.Page; import java.time.Instant; @@ -30,7 +30,7 @@ public record UserResponseDTO( /// A paginated DTO representing a list of subscriptions /// - /// @param users the [UserResponseDTO] list representing the users + /// @param users the DTO list representing the users /// @param first whether this is the first page /// @param last whether this is the last page /// @param page the current page number @@ -48,10 +48,10 @@ public record UserPageDTO( int numberOfElements, int size ) { - /// Returns a paginated response with details from a [Page] of users + /// Returns a paginated response with details from a paginated list of users /// - /// @param page the [Page] of [UserResponseDTO] items - /// @return a [UserPageDTO] with pagination details filled out + /// @param page a paginated list of user DTOs + /// @return a DTO with pagination details filled out public static UserPageDTO fromPage(Page<@NonNull UserResponseDTO> page) { return new UserPageDTO( page.getContent(), diff --git a/src/main/java/org/openpodcastapi/opa/user/UserEntity.java b/src/main/java/org/openpodcastapi/opa/user/UserEntity.java index a47aa3f..88ca380 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserEntity.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserEntity.java @@ -1,10 +1,9 @@ package org.openpodcastapi.opa.user; import jakarta.persistence.*; -import lombok.*; +import org.openpodcastapi.opa.security.RefreshTokenEntity; import org.openpodcastapi.opa.subscription.UserSubscriptionEntity; -import java.io.Serializable; import java.time.Instant; import java.util.Collections; import java.util.HashSet; @@ -14,16 +13,10 @@ /// An entity representing a user @Entity @Table(name = "users") -@Builder -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -public class UserEntity implements Serializable { +public class UserEntity { /// The user ID @Id - @Generated @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -43,13 +36,16 @@ public class UserEntity implements Serializable { @Column(nullable = false, unique = true) private String email; - /// A list of [UserSubscriptionEntity] associated with the user + /// A list of user subscriptions associated with the user @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE) - private transient Set subscriptions; + private Set subscriptions; + + /// A list of refresh tokens associated with the user + @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE) + private Set refreshTokens; /// The user's associated roles @ElementCollection(fetch = FetchType.EAGER) - @Builder.Default @Enumerated(EnumType.STRING) @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id")) private Set userRoles = new HashSet<>(Collections.singletonList(UserRoles.USER)); @@ -61,20 +57,158 @@ public class UserEntity implements Serializable { /// The date at which the entity was last updated private Instant updatedAt; + /// No-args constructor + public UserEntity() { + } + + /// Required-args constructor + /// + /// @param id the ID of the user + /// @param uuid the UUID of the user + /// @param username the username of the user + /// @param email the email address of the user + public UserEntity(Long id, UUID uuid, String username, String email) { + this(id, uuid, username, "", email, Instant.now(), Instant.now()); + } + + /// All-args constructor + /// + /// @param id the ID of the user + /// @param uuid the UUID of the user + /// @param username the user's username + /// @param password the user's hashed password + /// @param email the user's email address + /// @param createdAt the date at which the user was created + /// @param updatedAt the date at which the user was last updated + public UserEntity(Long id, UUID uuid, String username, String password, String email, Instant createdAt, Instant updatedAt) { + this.id = id; + this.uuid = uuid; + this.username = username; + this.password = password; + this.email = email; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /// Retrieves the ID of a user entity + /// + /// @return the ID of the user entity + public Long getId() { + return this.id; + } + + /// Retrieves the UUID of a user entity + /// + /// @return the UUID of the user entity + public UUID getUuid() { + return this.uuid; + } + + /// Sets the UUID of a user entity + /// + /// @param uuid the UUID for the entity + public void setUuid(UUID uuid) { + this.uuid = uuid; + } + + /// Retrieves the username of a user entity + /// + /// @return a user's username + public String getUsername() { + return this.username; + } + + /// Sets the username of a user entity + /// + /// @param username the user's username + public void setUsername(String username) { + this.username = username; + } + + /// Retrieves the user's password hash + /// + /// @return the hashed password + public String getPassword() { + return this.password; + } + + /// Sets the user's password. The password must be hashed first + /// + /// @param password the hashed password + public void setPassword(String password) { + this.password = password; + } + + /// Retrieves a user's email address + /// + /// @return the user's email address + public String getEmail() { + return this.email; + } + + /// Sets the user's email address. + /// + /// @param email the user's email address + public void setEmail(String email) { + this.email = email; + } + + /// Retrieves a user's subscriptions + /// + /// @return a set of subscriptions + public Set getSubscriptions() { + return this.subscriptions; + } + + /// Sets a user's subscriptions + /// + /// @param subscriptions the set of subscriptions to add to the user + public void setSubscriptions(Set subscriptions) { + this.subscriptions = subscriptions; + } + + /// Retrieves a user's roles + /// + /// @return a set of user roles + public Set getUserRoles() { + return this.userRoles; + } + + /// Sets a user's roles + /// + /// @param userRoles a set of user roles + public void setUserRoles(Set userRoles) { + this.userRoles = userRoles; + } + + /// Retrieves the creation date of the user + /// + /// @return the user creation date + public Instant getCreatedAt() { + return this.createdAt; + } + + /// Retrieves the last updated timestamp for the user + /// + /// @return the last updated timestamp + public Instant getUpdatedAt() { + return this.updatedAt; + } + /// Performs actions when the entity is initially saved @PrePersist public void prePersist() { this.setUuid(UUID.randomUUID()); - final Instant timestamp = Instant.now(); + final var timestamp = Instant.now(); // Store the created date and set an updated timestamp - this.setCreatedAt(timestamp); - this.setUpdatedAt(timestamp); + this.createdAt = timestamp; + this.updatedAt = timestamp; } /// Performs actions when the entity is updated @PreUpdate public void preUpdate() { // Store the timestamp of the update - this.setUpdatedAt(Instant.now()); + this.updatedAt = Instant.now(); } } diff --git a/src/main/java/org/openpodcastapi/opa/user/UserMapper.java b/src/main/java/org/openpodcastapi/opa/user/UserMapper.java index 63d0413..a412e77 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserMapper.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserMapper.java @@ -6,24 +6,21 @@ /// Mapper for user items @Mapper(componentModel = "spring") public interface UserMapper { - /// Maps a [UserEntity] to a [UserDTO.UserResponseDTO] + /// Maps a user entity to a DTO. /// - /// @param userEntity the [UserEntity] to map - /// @return the mapped [UserDTO.UserResponseDTO] + /// @param userEntity the entity to map + /// @return the mapped DTO UserDTO.UserResponseDTO toDto(UserEntity userEntity); - /// Maps a [UserDTO.CreateUserDTO] to a [UserEntity]. + /// Maps a user creation DTO to an entity. /// This mapper ignores all fields other than the username and email address. /// Other items are populated prior to persistence. /// - /// @param dto the [UserDTO.CreateUserDTO] to map - /// @return the mapped [UserEntity] + /// @param dto the user creation DTO to map + /// @return the mapped entity @Mapping(target = "uuid", ignore = true) - @Mapping(target = "id", ignore = true) @Mapping(target = "subscriptions", ignore = true) @Mapping(target = "password", ignore = true) @Mapping(target = "userRoles", ignore = true) - @Mapping(target = "updatedAt", ignore = true) - @Mapping(target = "createdAt", ignore = true) UserEntity toEntity(UserDTO.CreateUserDTO dto); } diff --git a/src/main/java/org/openpodcastapi/opa/user/UserRepository.java b/src/main/java/org/openpodcastapi/opa/user/UserRepository.java index 087d3e9..6eef061 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserRepository.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserRepository.java @@ -1,6 +1,6 @@ package org.openpodcastapi.opa.user; -import lombok.NonNull; +import org.jspecify.annotations.NonNull; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -13,18 +13,18 @@ public interface UserRepository extends JpaRepository<@NonNull UserEntity, @NonN /// Finds a single user by UUID. Returns `null` if no entity is found. /// /// @param uuid the UUID of the user - /// @return the found [UserEntity] + /// @return the found user Optional findUserByUuid(UUID uuid); /// Finds a single user by username. Returns `null` if no entity is found. /// /// @param username the username of the user - /// @return the found [UserEntity] + /// @return the found user Optional findUserByUsername(String username); /// Performs a check to see if there is an existing entity with the same username or email address /// - /// @param email the email address to check + /// @param email the email address to check /// @param username the username to check /// @return a boolean value representing whether an existing user was found boolean existsUserByEmailOrUsername(String email, String username); diff --git a/src/main/java/org/openpodcastapi/opa/user/UserRestController.java b/src/main/java/org/openpodcastapi/opa/user/UserRestController.java index ac87019..dc2c27d 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserRestController.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserRestController.java @@ -1,7 +1,6 @@ package org.openpodcastapi.opa.user; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.NonNull; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -13,17 +12,22 @@ /// Controller for user-related API requests @RestController -@RequiredArgsConstructor @RequestMapping("/api/v1/users") public class UserRestController { private final UserService service; + /// Required-args constructor + /// + /// @param userService the user service used to handle user interactions + public UserRestController(UserService userService) { + this.service = userService; + } + /// Returns all users. Only accessible to admins. /// - /// @param pageable the [Pageable] options used for pagination - /// @return a [ResponseEntity] containing [UserDTO.UserPageDTO] objects + /// @param pageable the pagination options + /// @return a response containing user objects @GetMapping - @ResponseStatus(HttpStatus.OK) @PreAuthorize("hasRole('ADMIN')") public ResponseEntity getAllUsers(Pageable pageable) { final var paginatedUserResponse = service.getAllUsers(pageable); @@ -33,10 +37,9 @@ public class UserRestController { /// Creates a new user in the system /// - /// @param request a [UserDTO.CreateUserDTO] request body - /// @return a [ResponseEntity] containing [UserDTO.UserResponseDTO] objects + /// @param request a user creation request body + /// @return a response containing user objects @PostMapping - @ResponseStatus(HttpStatus.CREATED) public ResponseEntity createUser(@RequestBody @Validated UserDTO.CreateUserDTO request) { // Create and persist the user final var userResponseDTO = service.createAndPersistUser(request); @@ -47,8 +50,8 @@ public class UserRestController { /// Fetch a specific user by UUID /// - /// @param uuid the [UUID] of the user - /// @return a [ResponseEntity] containing a summary of the action + /// @param uuid the UUID of the user + /// @return a response containing a summary of the action @DeleteMapping("/{uuid}") @PreAuthorize("hasRole('ADMIN') or #uuid == principal.uuid") public ResponseEntity<@NonNull String> deleteUser(@PathVariable String uuid) { diff --git a/src/main/java/org/openpodcastapi/opa/user/UserService.java b/src/main/java/org/openpodcastapi/opa/user/UserService.java index 1cf9086..e9e48f0 100644 --- a/src/main/java/org/openpodcastapi/opa/user/UserService.java +++ b/src/main/java/org/openpodcastapi/opa/user/UserService.java @@ -1,9 +1,8 @@ package org.openpodcastapi.opa.user; import jakarta.persistence.EntityNotFoundException; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,18 +14,28 @@ /// Service class for user-related actions @Service -@RequiredArgsConstructor -@Log4j2 public class UserService { private static final String USER_NOT_FOUND = "User not found"; + private static final Logger log = org.slf4j.LoggerFactory.getLogger(UserService.class); private final UserRepository repository; private final UserMapper mapper; private final BCryptPasswordEncoder passwordEncoder; + /// Required-args constructor + /// + /// @param repository the user repository used for user interactions + /// @param mapper the user mapper used to map user entities and DTOs + /// @param passwordEncoder the password encoder used to handle user passwords + public UserService(UserRepository repository, UserMapper mapper, BCryptPasswordEncoder passwordEncoder) { + this.repository = repository; + this.mapper = mapper; + this.passwordEncoder = passwordEncoder; + } + /// Persists a user to the database /// - /// @param dto the [UserDTO.CreateUserDTO] for the user - /// @return the formatted [UserDTO.UserResponseDTO] representation of the user + /// @param dto the user creation DTO for the user + /// @return the formatted DTO representation of the user /// @throws DataIntegrityViolationException if a user with a matching username or email address exists already @Transactional public UserDTO.UserResponseDTO createAndPersistUser(UserDTO.CreateUserDTO dto) throws DataIntegrityViolationException { @@ -49,8 +58,8 @@ public UserDTO.UserResponseDTO createAndPersistUser(UserDTO.CreateUserDTO dto) t /// Fetches a list of all users in the system. /// Intended for use by admins only. /// - /// @param pageable the [Pageable] object containing pagination options - /// @return a [Page] iterable of [UserDTO.UserResponseDTO] objects + /// @param pageable the pagination options + /// @return a paginated list of user objects @Transactional(readOnly = true) public Page getAllUsers(Pageable pageable) { final var paginatedUserDTO = repository.findAll(pageable); @@ -62,7 +71,7 @@ public UserDTO.UserResponseDTO createAndPersistUser(UserDTO.CreateUserDTO dto) t /// Deletes a user from the database /// - /// @param uuid the [UUID] of the user to delete + /// @param uuid the UUID of the user to delete /// @return a success message /// @throws EntityNotFoundException if no matching record is found @Transactional diff --git a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java index 1beb27e..d9d8c9a 100644 --- a/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java +++ b/src/main/java/org/openpodcastapi/opa/util/AdminUserInitializer.java @@ -1,11 +1,10 @@ package org.openpodcastapi.opa.util; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; +import org.jspecify.annotations.NonNull; import org.openpodcastapi.opa.user.UserEntity; import org.openpodcastapi.opa.user.UserRepository; import org.openpodcastapi.opa.user.UserRoles; +import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; @@ -14,11 +13,12 @@ import java.util.Set; +import static org.slf4j.LoggerFactory.getLogger; + /// Creates a default admin user for the system @Component -@RequiredArgsConstructor -@Log4j2 public class AdminUserInitializer implements ApplicationRunner { + private static final Logger log = getLogger(AdminUserInitializer.class); private final UserRepository userRepository; private final BCryptPasswordEncoder encoder; @Value("${admin.username}") @@ -28,6 +28,15 @@ public class AdminUserInitializer implements ApplicationRunner { @Value("${admin.email}") private String email; + /// Required-args constructor + /// + /// @param userRepository the user repository used for user interactions + /// @param encoder the password encoder used to encrypt the admin password + public AdminUserInitializer(UserRepository userRepository, BCryptPasswordEncoder encoder) { + this.userRepository = userRepository; + this.encoder = encoder; + } + /// Creates a default admin user for the system /// /// @param args the application arguments diff --git a/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java b/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java index 94c9ebe..0f046a8 100644 --- a/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java +++ b/src/main/java/org/openpodcastapi/opa/util/RefreshTokenCleanup.java @@ -1,22 +1,29 @@ package org.openpodcastapi.opa.util; -import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; import org.openpodcastapi.opa.security.RefreshTokenRepository; +import org.slf4j.Logger; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; +import static org.slf4j.LoggerFactory.getLogger; + /// A scheduled task to clean up expired refresh tokens @Component -@RequiredArgsConstructor -@Log4j2 public class RefreshTokenCleanup { + private static final Logger log = getLogger(RefreshTokenCleanup.class); private final RefreshTokenRepository repository; + /// Required-args constructor + /// + /// @param repository the refresh token repository for handling refresh token interactions + public RefreshTokenCleanup(RefreshTokenRepository repository) { + this.repository = repository; + } + /// Runs a task every hour to clean up expired refresh tokens @Scheduled(cron = "0 0 * * * ?") @Transactional diff --git a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java index af0f7e3..d16cf41 100644 --- a/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java +++ b/src/test/java/org/openpodcastapi/opa/auth/AuthApiTest.java @@ -2,31 +2,21 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.openpodcastapi.opa.security.RefreshTokenRepository; -import org.openpodcastapi.opa.security.TokenService; +import org.openpodcastapi.opa.user.UserDTO; import org.openpodcastapi.opa.user.UserEntity; +import org.openpodcastapi.opa.user.UserMapper; import org.openpodcastapi.opa.user.UserRepository; -import org.openpodcastapi.opa.user.UserRoles; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.restdocs.test.autoconfigure.AutoConfigureRestDocs; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.http.MediaType; -import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import java.time.Instant; -import java.util.Optional; -import java.util.Set; import java.util.UUID; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.when; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; @@ -40,51 +30,25 @@ @AutoConfigureRestDocs(outputDir = "target/generated-snippets") class AuthApiTest { + private final String password = "testPassword"; @Autowired MockMvc mockMvc; - - @MockitoBean + @Autowired private BCryptPasswordEncoder passwordEncoder; - - @MockitoBean + @Autowired private UserRepository userRepository; - - @MockitoBean - @Qualifier("apiLoginManager") - private AuthenticationManager authenticationManager; - - @MockitoBean - private RefreshTokenRepository refreshTokenRepository; - - @MockitoBean - private TokenService tokenService; + @Autowired + private UserMapper userMapper; + private UserEntity mockUser; @BeforeEach void setup() { - // Mock the userEntity lookup - UserEntity mockUserEntity = UserEntity.builder() - .id(2L) - .uuid(UUID.randomUUID()) - .email("test@test.test") - .password("password") - .username("test_user") - .createdAt(Instant.now()) - .updatedAt(Instant.now()) - .userRoles(Set.of(UserRoles.USER)) - .build(); - - // Mock repository behavior for finding user by username - when(userRepository.findUserByUsername("test_user")).thenReturn(Optional.of(mockUserEntity)); - - // Mock the refresh token validation to return the mock user - when(tokenService.validateRefreshToken(anyString(), any(UserEntity.class))) - .thenReturn(mockUserEntity); - - // Mock the access token generation - when(tokenService.generateAccessToken(any(UserEntity.class))).thenReturn("eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiI2MmJjZjczZC0xNGVjLTRkZmMtOGY5ZS1hMDQ0YjE4YjJiYTUiLCJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNzYzODQzMzEwLCJleHAiOjE3NjM4NDQyMTB9.B9aj5DoVpNe6HTxXm8iTHj5XaqFCcR1ZHRZq6xiqY28YvGGStVkPpedDVZfc02-B"); - - // Mock the refresh token generation - when(tokenService.generateRefreshToken(any(UserEntity.class))).thenReturn("8be54fc2-70ec-48ef-a8ff-4548fd8932b8e947a7ab-99b5-4cfb-b546-ac37eafa6c98"); + userRepository.deleteAll(); + final var mockUserDetails = new UserDTO.CreateUserDTO("user", password, "test@test.test"); + final var convertedUser = userMapper.toEntity(mockUserDetails); + convertedUser.setUuid(UUID.randomUUID()); + convertedUser.setPassword(passwordEncoder.encode(password)); + mockUser = userRepository.save(convertedUser); } @Test @@ -92,8 +56,8 @@ void authenticate_and_get_tokens() throws Exception { mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - { "username": "test_user", "password": "password" } - """)) + { "username": "%s", "password": "%s" } + """.formatted(mockUser.getUsername(), password))) .andExpect(status().isOk()) .andExpect(jsonPath("$.accessToken").exists()) .andExpect(jsonPath("$.refreshToken").exists()) @@ -118,8 +82,8 @@ void refresh_token_flow() throws Exception { String json = mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(""" - { "username": "test_user", "password": "password" } - """)) + { "username": "%s", "password": "%s" } + """.formatted(mockUser.getUsername(), password))) .andReturn() .getResponse() .getContentAsString(); @@ -133,7 +97,7 @@ void refresh_token_flow() throws Exception { "username": "%s", "refreshToken": "%s" } - """.formatted("test_user", refresh))) + """.formatted(mockUser.getUsername(), refresh))) .andExpect(status().isOk()) .andExpect(jsonPath("$.accessToken").exists()) .andExpect(jsonPath("$.expiresIn").exists()) diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java similarity index 75% rename from src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java rename to src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java index c488801..348d8f5 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionRestControllerTest.java @@ -1,38 +1,29 @@ package org.openpodcastapi.opa.subscriptions; -import jakarta.persistence.EntityNotFoundException; -import lombok.NonNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.openpodcastapi.opa.security.TokenService; import org.openpodcastapi.opa.subscription.SubscriptionDTO; +import org.openpodcastapi.opa.subscription.SubscriptionRepository; import org.openpodcastapi.opa.subscription.SubscriptionService; +import org.openpodcastapi.opa.user.UserDTO; import org.openpodcastapi.opa.user.UserEntity; +import org.openpodcastapi.opa.user.UserMapper; import org.openpodcastapi.opa.user.UserRepository; -import org.openpodcastapi.opa.user.UserRoles; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.restdocs.test.autoconfigure.AutoConfigureRestDocs; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import tools.jackson.databind.json.JsonMapper; -import java.time.Instant; import java.util.List; -import java.util.Optional; -import java.util.Set; import java.util.UUID; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.when; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; @@ -48,7 +39,7 @@ @ActiveProfiles("test") @AutoConfigureMockMvc @AutoConfigureRestDocs(outputDir = "target/generated-snippets") -class SubscriptionEntityRestControllerTest { +class SubscriptionRestControllerTest { @Autowired private MockMvc mockMvc; @@ -58,32 +49,32 @@ class SubscriptionEntityRestControllerTest { @Autowired private TokenService tokenService; - @MockitoBean + @Autowired private UserRepository userRepository; - @MockitoBean + @Autowired + private UserMapper userMapper; + + @Autowired private SubscriptionService subscriptionService; - private String accessToken; + @Autowired + private SubscriptionRepository subscriptionRepository; + + @Autowired + private BCryptPasswordEncoder passwordEncoder; private UserEntity mockUser; @BeforeEach void setup() { - mockUser = UserEntity - .builder() - .id(1L) - .uuid(UUID.randomUUID()) - .username("user") - .email("user@test.test") - .userRoles(Set.of(UserRoles.USER)) - .createdAt(Instant.now()) - .updatedAt(Instant.now()) - .build(); - - when(userRepository.findUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser)); - - accessToken = tokenService.generateAccessToken(mockUser); + userRepository.deleteAll(); + subscriptionRepository.deleteAll(); + final var mockUserDetails = new UserDTO.CreateUserDTO("user", "testPassword", "test@test.test"); + final var convertedUser = userMapper.toEntity(mockUserDetails); + convertedUser.setUuid(UUID.randomUUID()); + convertedUser.setPassword(passwordEncoder.encode("testPassword")); + mockUser = userRepository.save(convertedUser); } @Test @@ -95,14 +86,16 @@ void getAllSubscriptionsForAnonymous_shouldReturn401() throws Exception { } @Test - @WithMockUser(username = "user") void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { - SubscriptionDTO.UserSubscriptionDTO sub1 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), null); - SubscriptionDTO.UserSubscriptionDTO sub2 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), null); - Page page = new PageImpl<>(List.of(sub1, sub2)); + final var accessToken = tokenService.generateAccessToken(mockUser); + + final var uuid1 = UUID.randomUUID(); + final var uuid2 = UUID.randomUUID(); + + final var sub1DTO = new SubscriptionDTO.SubscriptionCreateDTO(uuid1.toString(), "test.com/feed1"); + final var sub2DTO = new SubscriptionDTO.SubscriptionCreateDTO(uuid2.toString(), "test.com/feed2"); - when(subscriptionService.getAllActiveSubscriptionsForUser(eq(mockUser.getId()), any(Pageable.class))) - .thenReturn(page); + subscriptionService.addSubscriptions(List.of(sub1DTO, sub2DTO), mockUser.getId()); mockMvc.perform(get("/api/v1/subscriptions") .header("Authorization", "Bearer " + accessToken) @@ -141,14 +134,18 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { } @Test - @WithMockUser(username = "user") void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws Exception { - SubscriptionDTO.UserSubscriptionDTO sub1 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), null); - SubscriptionDTO.UserSubscriptionDTO sub2 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), Instant.now()); - Page page = new PageImpl<>(List.of(sub1, sub2)); + final var accessToken = tokenService.generateAccessToken(mockUser); + + final var uuid1 = UUID.randomUUID(); + final var uuid2 = UUID.randomUUID(); + + final var sub1DTO = new SubscriptionDTO.SubscriptionCreateDTO(uuid1.toString(), "test.com/feed1"); + final var sub2DTO = new SubscriptionDTO.SubscriptionCreateDTO(uuid2.toString(), "test.com/feed2"); + + subscriptionService.addSubscriptions(List.of(sub1DTO, sub2DTO), mockUser.getId()); - when(subscriptionService.getAllSubscriptionsForUser(eq(mockUser.getId()), any(Pageable.class))) - .thenReturn(page); + subscriptionService.unsubscribeUserFromFeed(uuid2, mockUser.getId()); mockMvc.perform(get("/api/v1/subscriptions") .header("Authorization", "Bearer " + accessToken) @@ -168,10 +165,8 @@ void getSubscriptionByUuidForAnonymous_shouldReturnUnauthorized() throws Excepti } @Test - @WithMockUser(username = "test") void getNonexistentSubscription_shouldReturnNotFound() throws Exception { - when(subscriptionService.getUserSubscriptionBySubscriptionUuid(any(UUID.class), anyLong())) - .thenThrow(new EntityNotFoundException()); + final var accessToken = tokenService.generateAccessToken(mockUser); mockMvc.perform(get("/api/v1/subscriptions/{uuid}", UUID.randomUUID()) .header("Authorization", "Bearer " + accessToken)) @@ -179,15 +174,16 @@ void getNonexistentSubscription_shouldReturnNotFound() throws Exception { } @Test - @WithMockUser(username = "user") void getSubscriptionByUuid_shouldReturnSubscription() throws Exception { - UUID subscriptionUuid = UUID.randomUUID(); + final var accessToken = tokenService.generateAccessToken(mockUser); - SubscriptionDTO.UserSubscriptionDTO sub = new SubscriptionDTO.UserSubscriptionDTO(subscriptionUuid, "test.com/feed1", Instant.now(), Instant.now(), null); - when(subscriptionService.getUserSubscriptionBySubscriptionUuid(subscriptionUuid, mockUser.getId())) - .thenReturn(sub); + final var uuid1 = UUID.randomUUID(); - mockMvc.perform(get("/api/v1/subscriptions/{uuid}", subscriptionUuid) + final var sub1DTO = new SubscriptionDTO.SubscriptionCreateDTO(uuid1.toString(), "test.com/feed1"); + + subscriptionService.addSubscriptions(List.of(sub1DTO), mockUser.getId()); + + mockMvc.perform(get("/api/v1/subscriptions/{uuid}", uuid1) .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()) .andDo(document("subscription-get", @@ -217,8 +213,9 @@ void createUserSubscriptionWithAnonymousUser_shouldReturnUnauthorized() throws E } @Test - @WithMockUser(username = "user") void createUserSubscriptionsWithoutBody_shouldReturnBadRequest() throws Exception { + final var accessToken = tokenService.generateAccessToken(mockUser); + mockMvc.perform(post("/api/v1/subscriptions") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON)) @@ -226,28 +223,19 @@ void createUserSubscriptionsWithoutBody_shouldReturnBadRequest() throws Exceptio } @Test - @WithMockUser(username = "user") void createUserSubscriptions_shouldReturnMixedResponse() throws Exception { - final Instant timestamp = Instant.now(); + final var accessToken = tokenService.generateAccessToken(mockUser); - final UUID goodFeedUUID = UUID.randomUUID(); - final String BAD_UUID = "62ad30ce-aac0-4f0a-a811"; + final var uuid1 = UUID.randomUUID(); + final var BAD_UUID = "62ad30ce-aac0-4f0a-a811"; - SubscriptionDTO.SubscriptionCreateDTO dto1 = new SubscriptionDTO.SubscriptionCreateDTO(goodFeedUUID.toString(), "test.com/feed1"); - SubscriptionDTO.SubscriptionCreateDTO dto2 = new SubscriptionDTO.SubscriptionCreateDTO(BAD_UUID, "test.com/feed2"); - - SubscriptionDTO.BulkSubscriptionResponseDTO response = new SubscriptionDTO.BulkSubscriptionResponseDTO( - List.of(new SubscriptionDTO.UserSubscriptionDTO(goodFeedUUID, "test.com/feed1", timestamp, timestamp, null)), - List.of(new SubscriptionDTO.SubscriptionFailureDTO(BAD_UUID, "test.com/feed2", "invalid UUID format")) - ); - - when(subscriptionService.addSubscriptions(anyList(), eq(mockUser.getId()))) - .thenReturn(response); + final var sub1DTO = new SubscriptionDTO.SubscriptionCreateDTO(uuid1.toString(), "test.com/feed1"); + final var sub2DTO = new SubscriptionDTO.SubscriptionCreateDTO(BAD_UUID, "test.com/feed1"); mockMvc.perform(post("/api/v1/subscriptions") .header("Authorization", "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) - .content(jsonMapper.writeValueAsString(List.of(dto1, dto2)))) + .content(jsonMapper.writeValueAsString(List.of(sub1DTO, sub2DTO)))) .andExpect(status().isMultiStatus()) .andDo(document("subscriptions-bulk-create-mixed", preprocessRequest(prettyPrint()), @@ -275,20 +263,10 @@ void createUserSubscriptions_shouldReturnMixedResponse() throws Exception { } @Test - @WithMockUser(username = "user") void createUserSubscription_shouldReturnSuccess() throws Exception { - final UUID goodFeedUUID = UUID.randomUUID(); - final Instant timestamp = Instant.now(); - - SubscriptionDTO.SubscriptionCreateDTO dto = new SubscriptionDTO.SubscriptionCreateDTO(goodFeedUUID.toString(), "test.com/feed1"); + final var accessToken = tokenService.generateAccessToken(mockUser); - final var response = new SubscriptionDTO.BulkSubscriptionResponseDTO( - List.of(new SubscriptionDTO.UserSubscriptionDTO(goodFeedUUID, "test.com/feed1", timestamp, timestamp, null)), - List.of() - ); - - when(subscriptionService.addSubscriptions(anyList(), eq(mockUser.getId()))) - .thenReturn(response); + final var dto = new SubscriptionDTO.SubscriptionCreateDTO(UUID.randomUUID().toString(), "test.com/feed1"); mockMvc.perform(post("/api/v1/subscriptions") .header("Authorization", "Bearer " + accessToken) @@ -316,19 +294,10 @@ void createUserSubscription_shouldReturnSuccess() throws Exception { } @Test - @WithMockUser(username = "user") void createUserSubscription_shouldReturnFailure() throws Exception { - final String BAD_UUID = "62ad30ce-aac0-4f0a-a811"; - - final var dto = new SubscriptionDTO.SubscriptionCreateDTO(BAD_UUID, "test.com/feed2"); - - final var response = new SubscriptionDTO.BulkSubscriptionResponseDTO( - List.of(), - List.of(new SubscriptionDTO.SubscriptionFailureDTO(BAD_UUID, "test.com/feed2", "invalid UUID format")) - ); + final var accessToken = tokenService.generateAccessToken(mockUser); - when(subscriptionService.addSubscriptions(anyList(), eq(mockUser.getId()))) - .thenReturn(response); + final var dto = new SubscriptionDTO.SubscriptionCreateDTO("62ad30ce-aac0-4f0a-a811", "test.com/feed2"); mockMvc.perform(post("/api/v1/subscriptions") .header("Authorization", "Bearer " + accessToken) @@ -358,10 +327,8 @@ void unsubscribingWithAnonymousUser_shouldReturnUnauthorized() throws Exception } @Test - @WithMockUser(username = "user") void unsubscribingNonexistentEntity_shouldReturnNotFound() throws Exception { - when(subscriptionService.unsubscribeUserFromFeed(any(UUID.class), anyLong())) - .thenThrow(new EntityNotFoundException()); + final var accessToken = tokenService.generateAccessToken(mockUser); mockMvc.perform(post("/api/v1/subscriptions/{uuid}/unsubscribe", UUID.randomUUID()) .header("Authorization", "Bearer " + accessToken) @@ -370,21 +337,13 @@ void unsubscribingNonexistentEntity_shouldReturnNotFound() throws Exception { } @Test - @WithMockUser(username = "user") void unsubscribe_shouldReturnUpdatedSubscription() throws Exception { - final var subscriptionUuid = UUID.randomUUID(); - final var timestamp = Instant.now(); + final var accessToken = tokenService.generateAccessToken(mockUser); - SubscriptionDTO.UserSubscriptionDTO updatedSubscription = new SubscriptionDTO.UserSubscriptionDTO( - subscriptionUuid, - "test.com/feed1", - timestamp, - timestamp, - timestamp - ); + final var subscriptionUuid = UUID.randomUUID(); + final var sub1DTO = new SubscriptionDTO.SubscriptionCreateDTO(subscriptionUuid.toString(), "test.com/feed1"); - when(subscriptionService.unsubscribeUserFromFeed(subscriptionUuid, mockUser.getId())) - .thenReturn(updatedSubscription); + subscriptionService.addSubscriptions(List.of(sub1DTO), mockUser.getId()); // Act & Assert mockMvc.perform(post("/api/v1/subscriptions/{uuid}/unsubscribe", subscriptionUuid) @@ -393,7 +352,7 @@ void unsubscribe_shouldReturnUpdatedSubscription() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.uuid").value(subscriptionUuid.toString())) .andExpect(jsonPath("$.feedUrl").value("test.com/feed1")) - .andExpect(jsonPath("$.unsubscribedAt").value(timestamp.toString())) + .andExpect(jsonPath("$.unsubscribedAt").exists()) .andDo(document("subscription-unsubscribe", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java similarity index 59% rename from src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java rename to src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java index 5ef50ef..821d79c 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionMapperTest.java @@ -9,14 +9,13 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.junit.jupiter.SpringExtension; -import java.time.Instant; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = UserSubscriptionMapperImpl.class) -class UserSubscriptionEntityMapperTest { +class UserSubscriptionMapperTest { @Autowired private UserSubscriptionMapper mapper; @@ -26,30 +25,12 @@ class UserSubscriptionEntityMapperTest { /// Tests that a [UserSubscriptionEntity] entity maps to a [SubscriptionDTO.UserSubscriptionDTO] representation @Test void testToDto() { - final Instant timestamp = Instant.now(); - final UUID uuid = UUID.randomUUID(); - UserEntity userEntity = UserEntity.builder() - .uuid(UUID.randomUUID()) - .username("test") - .email("test@test.test") - .createdAt(timestamp) - .updatedAt(timestamp) - .build(); + final var uuid = UUID.randomUUID(); + final var userEntity = new UserEntity(1L, UUID.randomUUID(), "test", "test@test.test"); - SubscriptionEntity subscriptionEntity = SubscriptionEntity.builder() - .uuid(UUID.randomUUID()) - .feedUrl("test.com/feed1") - .createdAt(timestamp) - .updatedAt(timestamp) - .build(); + final var subscriptionEntity = new SubscriptionEntity(UUID.randomUUID(), "test.com/feed1"); - UserSubscriptionEntity userSubscriptionEntity = UserSubscriptionEntity.builder() - .uuid(uuid) - .user(userEntity) - .subscription(subscriptionEntity) - .createdAt(timestamp) - .updatedAt(timestamp) - .build(); + final var userSubscriptionEntity = new UserSubscriptionEntity(uuid, userEntity, subscriptionEntity); SubscriptionDTO.UserSubscriptionDTO dto = mapper.toDto(userSubscriptionEntity); assertNotNull(dto); diff --git a/src/test/java/org/openpodcastapi/opa/user/UserEntityMapperTest.java b/src/test/java/org/openpodcastapi/opa/user/UserMapperTest.java similarity index 86% rename from src/test/java/org/openpodcastapi/opa/user/UserEntityMapperTest.java rename to src/test/java/org/openpodcastapi/opa/user/UserMapperTest.java index 8e85b97..9704283 100644 --- a/src/test/java/org/openpodcastapi/opa/user/UserEntityMapperTest.java +++ b/src/test/java/org/openpodcastapi/opa/user/UserMapperTest.java @@ -14,7 +14,7 @@ @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = UserMapperImpl.class) -class UserEntityMapperTest { +class UserMapperTest { @Autowired private UserMapper mapper; @@ -26,13 +26,7 @@ class UserEntityMapperTest { void testToDto() { final Instant timestamp = Instant.now(); final UUID uuid = UUID.randomUUID(); - UserEntity userEntity = UserEntity.builder() - .uuid(uuid) - .username("test") - .email("test@test.test") - .createdAt(timestamp) - .updatedAt(timestamp) - .build(); + final var userEntity = new UserEntity(1L, uuid, "test", "", "test@test.test", timestamp, timestamp); UserDTO.UserResponseDTO dto = mapper.toDto(userEntity); assertNotNull(dto); diff --git a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java index c3d5b29..3d68419 100644 --- a/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/user/UserRestControllerTest.java @@ -1,30 +1,21 @@ package org.openpodcastapi.opa.user; -import lombok.NonNull; -import lombok.extern.log4j.Log4j2; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.openpodcastapi.opa.security.TokenService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.restdocs.test.autoconfigure.AutoConfigureRestDocs; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; -import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import java.time.Instant; -import java.util.List; -import java.util.Optional; import java.util.Set; import java.util.UUID; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; @@ -40,20 +31,33 @@ @ActiveProfiles("test") @AutoConfigureMockMvc @AutoConfigureRestDocs(outputDir = "target/generated-snippets") -@Log4j2 class UserRestControllerTest { - @Autowired private MockMvc mockMvc; @Autowired private TokenService tokenService; - @MockitoBean + @Autowired private UserRepository userRepository; - @MockitoBean - private UserService userService; + @Autowired + private BCryptPasswordEncoder passwordEncoder; + + @Autowired + private UserMapper userMapper; + + private UserEntity mockUser; + + @BeforeEach + void setup() { + userRepository.deleteAll(); + final var mockUserDetails = new UserDTO.CreateUserDTO("user", "testPassword", "test@test.test"); + final var convertedUser = userMapper.toEntity(mockUserDetails); + convertedUser.setUuid(UUID.randomUUID()); + convertedUser.setPassword(passwordEncoder.encode("testPassword")); + mockUser = userRepository.save(convertedUser); + } @Test void getAllUsers_shouldReturn401_forAnonymousUser() throws Exception { @@ -63,44 +67,19 @@ void getAllUsers_shouldReturn401_forAnonymousUser() throws Exception { } @Test - @WithMockUser(username = "admin", roles = {"USER", "ADMIN"}) void getAllUsers_shouldReturn200_andList() throws Exception { - UserEntity mockUser = UserEntity - .builder() - .id(1L) - .uuid(UUID.randomUUID()) - .username("admin") - .email("admin@test.test") - .userRoles(Set.of(UserRoles.USER, UserRoles.ADMIN)) - .createdAt(Instant.now()) - .updatedAt(Instant.now()) - .build(); - - when(userRepository.findUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser)); + mockUser.setUserRoles(Set.of(UserRoles.USER, UserRoles.ADMIN)); + mockUser = userRepository.save(mockUser); - String accessToken = tokenService.generateAccessToken(mockUser); - - final Instant createdDate = Instant.now(); - - final UserDTO.UserResponseDTO user1 = new UserDTO.UserResponseDTO( - UUID.randomUUID(), - "alice", - "alice@test.com", - createdDate, - createdDate - ); + final var accessToken = tokenService.generateAccessToken(mockUser); - final UserDTO.UserResponseDTO user2 = new UserDTO.UserResponseDTO( - UUID.randomUUID(), - "bob", - "bob@test.com", - createdDate, - createdDate - ); - - // Mock the service call to return users - PageImpl page = new PageImpl<>(List.of(user1, user2), PageRequest.of(0, 2), 2); - when(userService.getAllUsers(any())).thenReturn(page); + // Mock a second user + final var uuid = UUID.randomUUID(); + final var dto = new UserDTO.CreateUserDTO("bob", "testPassword", "bob@test.test"); + final var convertedUser = userMapper.toEntity(dto); + convertedUser.setUuid(uuid); + convertedUser.setPassword(passwordEncoder.encode("testPassword")); + userRepository.save(convertedUser); // Perform the test for the admin role mockMvc.perform(get("/api/v1/users") @@ -137,21 +116,7 @@ void getAllUsers_shouldReturn200_andList() throws Exception { } @Test - @WithMockUser(username = "user", roles = "USER") void getAllUsers_shouldReturn403_forUserRole() throws Exception { - UserEntity mockUser = UserEntity - .builder() - .id(1L) - .uuid(UUID.randomUUID()) - .username("user") - .email("user@test.test") - .userRoles(Set.of(UserRoles.USER)) - .createdAt(Instant.now()) - .updatedAt(Instant.now()) - .build(); - - when(userRepository.findUserByUuid(any(UUID.class))).thenReturn(Optional.of(mockUser)); - String accessToken = tokenService.generateAccessToken(mockUser); mockMvc.perform(get("/api/v1/users") @@ -160,7 +125,7 @@ void getAllUsers_shouldReturn403_forUserRole() throws Exception { .param("page", "0") .param("size", "20")) .andExpect(status().isForbidden()) - .andDo(document("users-list", + .andDo(document("users-list-unsuccessful", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), queryParameters(