diff --git a/src/main/java/org/decepticons/linkshortener/LinkShortenerApplication.java b/src/main/java/org/decepticons/linkshortener/LinkShortenerApplication.java index 0c58d29..a0f235b 100644 --- a/src/main/java/org/decepticons/linkshortener/LinkShortenerApplication.java +++ b/src/main/java/org/decepticons/linkshortener/LinkShortenerApplication.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.scheduling.annotation.EnableScheduling; /** * Entry point for the Link Shortener application. @@ -11,6 +12,7 @@ *

*/ @EnableCaching +@EnableScheduling @SpringBootApplication public class LinkShortenerApplication { diff --git a/src/main/java/org/decepticons/linkshortener/api/exception/ExpiredTokenException.java b/src/main/java/org/decepticons/linkshortener/api/exception/ExpiredTokenException.java index 2b92426..2753459 100644 --- a/src/main/java/org/decepticons/linkshortener/api/exception/ExpiredTokenException.java +++ b/src/main/java/org/decepticons/linkshortener/api/exception/ExpiredTokenException.java @@ -1,6 +1,5 @@ package org.decepticons.linkshortener.api.exception; -import org.decepticons.linkshortener.api.exception.BaseException; /** * Thrown when a JWT token is expired. diff --git a/src/main/java/org/decepticons/linkshortener/api/model/RevokedToken.java b/src/main/java/org/decepticons/linkshortener/api/model/RevokedToken.java new file mode 100644 index 0000000..98d459c --- /dev/null +++ b/src/main/java/org/decepticons/linkshortener/api/model/RevokedToken.java @@ -0,0 +1,59 @@ +package org.decepticons.linkshortener.api.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Entity representing a revoked JWT token in the database. + * This is used for stateless token blacklisting. + */ +@Entity +@Table(name = "revoked_tokens") +@Getter +@Setter +@NoArgsConstructor +public class RevokedToken { + + /** + * The maximum length of a token string. + */ + private static final int MAX_TOKEN_LENGTH = 500; + + /** + * The unique identifier for the revoked token entry. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * The revoked JWT token string. + */ + @Column(nullable = false, unique = true, length = MAX_TOKEN_LENGTH) + private String token; + + /** + * The date and time at which the token expires. + */ + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + /** + * Constructs a new RevokedToken with the given token and expiration date. + * + * @param tokenParam the JWT token string to be revoked + * @param expiresAtParam the expiration date of the token + */ + public RevokedToken(final String tokenParam, final Instant expiresAtParam) { + this.token = tokenParam; + this.expiresAt = expiresAtParam; + } +} diff --git a/src/main/java/org/decepticons/linkshortener/api/repository/RevokedTokenRepository.java b/src/main/java/org/decepticons/linkshortener/api/repository/RevokedTokenRepository.java new file mode 100644 index 0000000..aaeafe6 --- /dev/null +++ b/src/main/java/org/decepticons/linkshortener/api/repository/RevokedTokenRepository.java @@ -0,0 +1,39 @@ +package org.decepticons.linkshortener.api.repository; + +import java.time.Instant; +import java.util.Optional; +import org.decepticons.linkshortener.api.model.RevokedToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Repository for managing revoked JWT tokens. + */ +@Repository +public interface RevokedTokenRepository extends + JpaRepository { + + /** + * Finds a revoked token by its unique token string. + * + * @param token The token string to search for. + * @return An Optional containing the found RevokedToken, + * or empty if not found. + */ + Optional findByToken(String token); + + /** + * Checks if a token exists in the repository. + * + * @param token The token string to check. + * @return true if the token exists, false otherwise. + */ + boolean existsByToken(String token); + + /** + * Deletes all revoked tokens that have expired before the given timestamp. + * + * @param now The timestamp used to determine which tokens to delete. + */ + void deleteAllByExpiresAtBefore(Instant now); +} diff --git a/src/main/java/org/decepticons/linkshortener/api/security/config/SecurityConfig.java b/src/main/java/org/decepticons/linkshortener/api/security/config/SecurityConfig.java index 34bb375..9864037 100644 --- a/src/main/java/org/decepticons/linkshortener/api/security/config/SecurityConfig.java +++ b/src/main/java/org/decepticons/linkshortener/api/security/config/SecurityConfig.java @@ -8,6 +8,7 @@ import org.decepticons.linkshortener.api.model.User; import org.decepticons.linkshortener.api.repository.UserRepository; import org.decepticons.linkshortener.api.security.jwt.JwtAuthenticationFilter; +import org.decepticons.linkshortener.api.security.model.CustomUserDetails; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -117,10 +118,7 @@ public UserDetailsService userDetailsService( "User was not found" )); - return org.springframework.security.core.userdetails.User - .withUsername(user.getUsername()) - .password(user.getPasswordHash()) - .build(); + return new CustomUserDetails(user); }; } diff --git a/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilter.java index 5dddb72..6eddd80 100644 --- a/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilter.java @@ -5,7 +5,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import org.decepticons.linkshortener.api.exception.InvalidTokenException; +import org.decepticons.linkshortener.api.repository.RevokedTokenRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -21,42 +21,64 @@ */ @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { - /** Logger for JWT authentication filter. */ - private static final Logger LOG = - LoggerFactory.getLogger(JwtAuthenticationFilter.class); - /** HTTP header containing the JWT token. */ + /** + * Logger for the JwtAuthenticationFilter class. + */ + private static final Logger LOG = LoggerFactory.getLogger( + JwtAuthenticationFilter.class + ); + + /** + * The name of the authorization header. + */ private static final String AUTHORIZATION_HEADER = "Authorization"; - /** Prefix used in the Authorization header. */ + /** + * The prefix for a Bearer token. + */ private static final String BEARER_PREFIX = "Bearer "; - /** Utility class for JWT operations. */ + /** + * Utility for handling JWT tokens. + */ private final JwtTokenUtil jwtTokenUtil; - /** Service to load user details from the database. */ + /** + * Service for loading user details. + */ private final UserDetailsService userDetailsService; /** - * Constructs a JwtAuthenticationFilter with required dependencies. + * Repository to check for revoked tokens. + */ + private final RevokedTokenRepository revokedTokenRepository; + + /** + * Constructs a new JwtAuthenticationFilter. * - * @param inJwtTokenUtil utility to parse and validate JWT tokens - * @param inUserDetailsService service to load user details + * @param jwtTokenUtilParam The JWT utility. + * @param userDetailsServiceParam The user details service. + * @param revokedTokenRepositoryParam The revoked token repository. */ - public JwtAuthenticationFilter(final JwtTokenUtil inJwtTokenUtil, - final UserDetailsService inUserDetailsService) { - this.jwtTokenUtil = inJwtTokenUtil; - this.userDetailsService = inUserDetailsService; + public JwtAuthenticationFilter( + final JwtTokenUtil jwtTokenUtilParam, + final UserDetailsService userDetailsServiceParam, + final RevokedTokenRepository revokedTokenRepositoryParam + ) { + this.jwtTokenUtil = jwtTokenUtilParam; + this.userDetailsService = userDetailsServiceParam; + this.revokedTokenRepository = revokedTokenRepositoryParam; } /** - * Filters each request to validate JWT tokens and set authentication. + * Filters incoming requests to validate JWT tokens and authenticate users. * - * @param request the HTTP request - * @param response the HTTP response - * @param filterChain the filter chain - * @throws ServletException if a servlet error occurs - * @throws IOException if an I/O error occurs + * @param request The servlet request. + * @param response The servlet response. + * @param filterChain The filter chain. + * @throws ServletException if a servlet-specific error occurs. + * @throws IOException if an I/O error occurs. */ @Override protected void doFilterInternal(final HttpServletRequest request, @@ -65,20 +87,24 @@ protected void doFilterInternal(final HttpServletRequest request, throws ServletException, IOException { final String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER); - String jwtToken; - if (authorizationHeader == null) { - LOG.debug("No JWT token, skipping authentication"); + if (authorizationHeader == null + || !authorizationHeader.startsWith(BEARER_PREFIX)) { filterChain.doFilter(request, response); return; } - if (!authorizationHeader.startsWith(BEARER_PREFIX)) { - LOG.warn("JWT Token does not begin with Bearer String"); - throw new InvalidTokenException("JWT Token must start with 'Bearer '"); - } + String jwtToken = authorizationHeader.substring(BEARER_PREFIX.length()); - jwtToken = authorizationHeader.substring(BEARER_PREFIX.length()); + // Check if the token is revoked + if (revokedTokenRepository.existsByToken(jwtToken)) { + LOG.warn("Revoked token used: {}", jwtToken); + SecurityContextHolder.clearContext(); + response.sendError( + HttpServletResponse.SC_UNAUTHORIZED, + "Token has been revoked"); + return; + } final String username = jwtTokenUtil.extractUsername(jwtToken); @@ -93,14 +119,21 @@ protected void doFilterInternal(final HttpServletRequest request, } if (!jwtTokenUtil.validateToken(jwtToken, userDetails)) { - throw new InvalidTokenException("Token is expired or invalid"); + SecurityContextHolder.clearContext(); + response.sendError( + HttpServletResponse.SC_UNAUTHORIZED, + "Token is expired or invalid"); + return; } UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken(userDetails, null, + new UsernamePasswordAuthenticationToken( + userDetails, + null, userDetails.getAuthorities()); authToken.setDetails( - new WebAuthenticationDetailsSource().buildDetails(request)); + new WebAuthenticationDetailsSource().buildDetails(request) + ); SecurityContextHolder.getContext().setAuthentication(authToken); } @@ -108,10 +141,10 @@ protected void doFilterInternal(final HttpServletRequest request, } /** - * Determines if this filter should not apply to a given request. + * Determines whether this filter should be applied to the current request. * - * @param request the HTTP request - * @return true if filter should be skipped, false otherwise + * @param request The servlet request. + * @return true if the filter should not be applied, false otherwise. */ @Override protected boolean shouldNotFilter(final HttpServletRequest request) { @@ -125,5 +158,4 @@ protected boolean shouldNotFilter(final HttpServletRequest request) { || path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs"); } - } diff --git a/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtTokenUtil.java b/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtTokenUtil.java index 7220ad5..d018087 100644 --- a/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtTokenUtil.java +++ b/src/main/java/org/decepticons/linkshortener/api/security/jwt/JwtTokenUtil.java @@ -18,31 +18,39 @@ /** * Utility class for generating and validating JWT tokens. - * Provides methods to create JWT tokens for authenticated users, - * refresh tokens, extract claims, and validate expiration. + * Provides methods to create JWT tokens for + * authenticated users, refresh tokens, extract claims, + * and validate expiration. */ @Component public class JwtTokenUtil { - /** The number of milliseconds in a second. */ + /** + * The number of milliseconds in a second. + */ private static final long MILLISECONDS_IN_A_SECOND = 1000L; - /** The signing key. */ + /** + * The signing key. + */ private final Key signingKey; - /** The token validity duration for access tokens in seconds. */ + /** + * The token validity duration for access tokens in seconds. + */ private final long expirationSeconds; - /** The token validity duration for refresh tokens in seconds. */ + /** + * The token validity duration for refresh tokens in seconds. + */ private final long refreshExpirationSeconds; /** * Constructs a JwtTokenUtil with the given secrets and expiration times. * * @param secretValue the secret key for signing JWT tokens - * @param expirationSecondsValue the access token validity duration in seconds - * @param refreshExpirationValue the refresh token validity - * duration in seconds + * @param expirationSecondsValue the access token validity duration (seconds) + * @param refreshExpirationValue the refresh token validity duration (seconds) */ public JwtTokenUtil( @Value("${JWT_SECRET}") final String secretValue, @@ -81,7 +89,7 @@ public String generateRefreshToken(final UserDetails userDetails) { /** * Validates a JWT token against user details and expiration. * - * @param token the JWT token to validate. + * @param token the JWT token to validate. * @param userDetails the user details to validate against. * @return true if the token is valid, false otherwise. */ @@ -97,6 +105,20 @@ public boolean validateToken( } } + /** + * Validates a JWT token by checking if it is expired. + * + * @param token the JWT token to validate. + * @return true if the token is valid and not expired, false otherwise. + */ + public boolean validateToken(final String token) { + try { + return !isTokenExpired(token); + } catch (InvalidTokenException e) { + return false; + } + } + /** * Extracts the username (subject) from a JWT token. * @@ -110,10 +132,10 @@ public String extractUsername(final String token) { /** * Extracts a specific claim from a JWT token. * - * @param the type of the claim to extract - * @param token the JWT token from which to extract the claim + * @param the type of the claim to extract + * @param token the JWT token from which to extract the claim * @param claimsResolver a function to resolve a specific claim - * from the token's claims + * from the token's claims * @return the extracted claim */ public T extractClaim( @@ -136,8 +158,8 @@ public Date extractExpiration(final String token) { /** * Builds a JWT token with claims and subject. * - * @param claims the claims to be included in the token. - * @param subject the subject of the token (usually the username). + * @param claims the claims to be included in the token. + * @param subject the subject of the token (username). * @param expirationSecondsParam the token validity in seconds. * @return the built JWT token string. */ @@ -165,13 +187,13 @@ private String createToken( private Claims extractAllClaims(final String token) { try { return Jwts.parserBuilder() - .setSigningKey(signingKey) - .build() - .parseClaimsJws(token) - .getBody(); + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token) + .getBody(); } catch (JwtException | IllegalArgumentException ex) { throw new InvalidTokenException( - "Invalid token provided during claims extraction.", ex); + "Invalid token provided during claims extraction.", ex); } } diff --git a/src/main/java/org/decepticons/linkshortener/api/security/model/CustomUserDetails.java b/src/main/java/org/decepticons/linkshortener/api/security/model/CustomUserDetails.java index 5321016..dc1f1a4 100644 --- a/src/main/java/org/decepticons/linkshortener/api/security/model/CustomUserDetails.java +++ b/src/main/java/org/decepticons/linkshortener/api/security/model/CustomUserDetails.java @@ -4,6 +4,7 @@ import java.util.Collection; import lombok.Getter; import org.decepticons.linkshortener.api.model.User; +import org.decepticons.linkshortener.api.model.UserStatus; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @@ -34,7 +35,7 @@ public CustomUserDetails(final User userParam) { /** * Returns the authorities granted to the user. * - * @return collection of granted authorities + * @return collection of granted authorities (roles) */ @Override public Collection getAuthorities() { @@ -63,7 +64,6 @@ public String getUsername() { /** * Indicates whether the user's account has expired. - * Subclasses may override to change the expiration logic. * * @return true if the account is not expired */ @@ -74,23 +74,32 @@ public boolean isAccountNonExpired() { /** * Indicates whether the user's account is locked. - * Subclasses may override to change the lock logic. * - * @return true if the account is not locked + * @return true if the user is not LOCKED */ @Override public boolean isAccountNonLocked() { - return true; + return user.getStatus() != null && user.getStatus() != UserStatus.LOCKED; } /** * Indicates whether the user's credentials are expired. - * Subclasses may override to change the credential expiration logic. * - * @return true if the credentials are valid + * @return true if credentials are valid */ @Override public boolean isCredentialsNonExpired() { return true; } + + /** + * Indicates whether the user is enabled. + * Here we consider ACTIVE as enabled, LOCKED as disabled. + * + * @return true if user is ACTIVE + */ + @Override + public boolean isEnabled() { + return user.getStatus() != null && user.getStatus() == UserStatus.ACTIVE; + } } diff --git a/src/main/java/org/decepticons/linkshortener/api/security/service/AuthService.java b/src/main/java/org/decepticons/linkshortener/api/security/service/AuthService.java index 9752e0e..8243517 100644 --- a/src/main/java/org/decepticons/linkshortener/api/security/service/AuthService.java +++ b/src/main/java/org/decepticons/linkshortener/api/security/service/AuthService.java @@ -34,4 +34,11 @@ public interface AuthService { * @return the newly registered User domain object */ User registerUser(User user); + + /** + * Logs out the user by revoking their token. + * + * @param token the token to be revoked + */ + void logout(String token); } diff --git a/src/main/java/org/decepticons/linkshortener/api/security/service/UserAuthService.java b/src/main/java/org/decepticons/linkshortener/api/security/service/UserAuthService.java deleted file mode 100644 index f6fd81a..0000000 --- a/src/main/java/org/decepticons/linkshortener/api/security/service/UserAuthService.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.decepticons.linkshortener.api.security.service; - -import org.decepticons.linkshortener.api.model.User; - -/** - * Service interface for user-related authentication and data retrieval. - * This service works exclusively with the User domain object. - */ -public interface UserAuthService { - - /** - * Finds a user by their username. - * - * @param username the username to search for - * @return the User object - */ - User findByUsername(String username); - - /** - * Registers a new user. - * - * @param user the User domain object to register - * @return the registered User object - */ - User registerUser(User user); -} diff --git a/src/main/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImpl.java b/src/main/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImpl.java index 9a4958f..5d7636d 100644 --- a/src/main/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImpl.java +++ b/src/main/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImpl.java @@ -1,17 +1,24 @@ package org.decepticons.linkshortener.api.security.service.impl; +import jakarta.transaction.Transactional; +import java.time.Instant; import java.util.Collections; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.decepticons.linkshortener.api.exception.InvalidPasswordException; import org.decepticons.linkshortener.api.exception.InvalidTokenException; +import org.decepticons.linkshortener.api.exception.UserAlreadyExistsException; +import org.decepticons.linkshortener.api.model.RevokedToken; import org.decepticons.linkshortener.api.model.Role; import org.decepticons.linkshortener.api.model.User; import org.decepticons.linkshortener.api.model.UserStatus; +import org.decepticons.linkshortener.api.repository.RevokedTokenRepository; import org.decepticons.linkshortener.api.repository.RoleRepository; import org.decepticons.linkshortener.api.repository.UserRepository; import org.decepticons.linkshortener.api.security.jwt.JwtTokenUtil; import org.decepticons.linkshortener.api.security.service.AuthService; -import org.decepticons.linkshortener.api.security.service.UserAuthService; +import org.decepticons.linkshortener.api.service.UserService; +import org.decepticons.linkshortener.api.util.PasswordValidator; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.crypto.password.PasswordEncoder; @@ -19,7 +26,8 @@ /** * Service implementation for user authentication and authorization. - * Handles user login, token refresh, and registration processes. + * Handles user login, token + * refresh, and registration processes. */ @Service @RequiredArgsConstructor @@ -33,7 +41,7 @@ public class AuthServiceImpl implements AuthService { /** * Service for handling user-related authentication operations. */ - private final UserAuthService userAuthService; + private final UserService userService; /** * Manages authentication requests and processes them. @@ -50,6 +58,11 @@ public class AuthServiceImpl implements AuthService { */ private final UserRepository userRepository; + /** + * Repository for managing revoked JWT tokens. + */ + private final RevokedTokenRepository revokedTokenRepository; + /** * Encodes and verifies user passwords. */ @@ -72,7 +85,7 @@ public User login(final String username, final String password) { authManager.authenticate( new UsernamePasswordAuthenticationToken(username, password) ); - return userAuthService.findByUsername(username); + return userService.findByUsername(username); } /** @@ -84,15 +97,26 @@ public User login(final String username, final String password) { */ @Override public User refreshToken(final String authorizationHeader) { - if (authorizationHeader == null - || !authorizationHeader.startsWith(BEARER_PREFIX)) { + if ( + authorizationHeader == null + || !authorizationHeader.startsWith(BEARER_PREFIX) + ) { throw new InvalidTokenException( "Missing or malformed Authorization header" ); } String jwtToken = authorizationHeader.substring(BEARER_PREFIX.length()); + + if (revokedTokenRepository.existsByToken(jwtToken)) { + throw new InvalidTokenException("Token has been revoked"); + } + + if (!jwtUtil.validateToken(jwtToken)) { + throw new InvalidTokenException("Invalid or expired token"); + } + String username = jwtUtil.extractUsername(jwtToken); - return userAuthService.findByUsername(username); + return userService.findByUsername(username); } /** @@ -103,6 +127,7 @@ public User refreshToken(final String authorizationHeader) { * @throws IllegalStateException if the default 'ROLE_USER' role is not found. */ @Override + @Transactional public User registerUser(final User user) { Optional userRole = roleRepository.findByName("ROLE_USER"); if (userRole.isEmpty()) { @@ -110,8 +135,41 @@ public User registerUser(final User user) { "Default role 'ROLE_USER' not found in the database." ); } + + if (userRepository.existsByUsername(user.getUsername())) { + throw new UserAlreadyExistsException(user.getUsername()); + } + + if (!PasswordValidator.isValid(user.getPasswordHash())) { + throw new InvalidPasswordException( + "Password does not meet complexity requirements" + ); + } + + user.setPasswordHash(passwordEncoder.encode(user.getPasswordHash())); user.setStatus(UserStatus.ACTIVE); user.setRoles(Collections.singleton(userRole.get())); - return userAuthService.registerUser(user); + return userRepository.save(user); + } + + /** + * Logs out the user by revoking their token. + * + * @param authHeader the Authorization header containing + * the token to be revoked + */ + @Override + @Transactional + public void logout(final String authHeader) { + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) { + throw new InvalidTokenException( + "Missing or malformed Authorization header" + ); + } + + String accessToken = authHeader.substring(BEARER_PREFIX.length()); + Instant accessExpiresAt = jwtUtil.extractExpiration(accessToken) + .toInstant(); + revokedTokenRepository.save(new RevokedToken(accessToken, accessExpiresAt)); } } diff --git a/src/main/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImpl.java b/src/main/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImpl.java deleted file mode 100644 index fb448d9..0000000 --- a/src/main/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImpl.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.decepticons.linkshortener.api.security.service.impl; - -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import org.decepticons.linkshortener.api.exception.InvalidPasswordException; -import org.decepticons.linkshortener.api.exception.UserAlreadyExistsException; -import org.decepticons.linkshortener.api.exception.UserNotFoundException; -import org.decepticons.linkshortener.api.model.User; -import org.decepticons.linkshortener.api.repository.UserRepository; -import org.decepticons.linkshortener.api.security.service.UserAuthService; -import org.decepticons.linkshortener.api.util.PasswordValidator; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -/** - * Implementation of {@link UserAuthService} that handles user registration - * and retrieval by username. Uses {@link UserRepository} for data access - * and {@link PasswordEncoder} for hashing passwords. - */ -@Service -@RequiredArgsConstructor -public class UserAuthServiceImpl implements UserAuthService { - - /** Repository for user data access. */ - private final UserRepository userRepository; - - /** Encoder for hashing user passwords. */ - private final PasswordEncoder passwordEncoder; - - /** - * Registers a new user. - * Throws an exception if the username already exists. - * - * @param newUser the User domain object to be registered - * @return the newly registered User object - */ - @Override - @Transactional - public User registerUser(final User newUser) { - String username = newUser.getUsername(); - String password = newUser.getPasswordHash(); - - if (userRepository.existsByUsername(username)) { - throw new UserAlreadyExistsException(username); - } - - if (!PasswordValidator.isValid(password)) { - throw new InvalidPasswordException( - "Password does not meet complexity requirements" - ); - } - - newUser.setPasswordHash(passwordEncoder.encode(password)); - userRepository.save(newUser); - - return newUser; - } - - /** - * Finds a user by their username. - * - * @param username the username to search for - * @return the User entity - * @throws UserNotFoundException if no user is found - */ - @Override - public User findByUsername(final String username) { - return userRepository.findByUsername(username) - .orElseThrow(() -> new UserNotFoundException(username) - ); - } -} diff --git a/src/main/java/org/decepticons/linkshortener/api/service/RevokedTokenCleanupService.java b/src/main/java/org/decepticons/linkshortener/api/service/RevokedTokenCleanupService.java new file mode 100644 index 0000000..59a9da8 --- /dev/null +++ b/src/main/java/org/decepticons/linkshortener/api/service/RevokedTokenCleanupService.java @@ -0,0 +1,11 @@ +package org.decepticons.linkshortener.api.service; + +/** + * Service interface for cleaning up revoked tokens. + */ +public interface RevokedTokenCleanupService { + /** + * Service interface for cleaning up revoked tokens. + */ + void cleanupExpiredRevokedTokens(); +} diff --git a/src/main/java/org/decepticons/linkshortener/api/service/UserService.java b/src/main/java/org/decepticons/linkshortener/api/service/UserService.java index 3cd29b3..98c6afa 100644 --- a/src/main/java/org/decepticons/linkshortener/api/service/UserService.java +++ b/src/main/java/org/decepticons/linkshortener/api/service/UserService.java @@ -4,7 +4,8 @@ import org.decepticons.linkshortener.api.model.User; /** - * Service interface for retrieving information about the currently authenticated user. + * Service interface for retrieving information + * about the currently authenticated user. */ public interface UserService { @@ -21,4 +22,13 @@ public interface UserService { * @return the UUID of the current user */ UUID getCurrentUserId(); + + /** + * Finds a user by their username. + * + * @param username the username to search for + * @return the User object + */ + User findByUsername(String username); + } diff --git a/src/main/java/org/decepticons/linkshortener/api/service/impl/RevokedTokenCleanupServiceImpl.java b/src/main/java/org/decepticons/linkshortener/api/service/impl/RevokedTokenCleanupServiceImpl.java new file mode 100644 index 0000000..383b3b1 --- /dev/null +++ b/src/main/java/org/decepticons/linkshortener/api/service/impl/RevokedTokenCleanupServiceImpl.java @@ -0,0 +1,40 @@ +package org.decepticons.linkshortener.api.service.impl; + +import java.time.Instant; +import org.decepticons.linkshortener.api.repository.RevokedTokenRepository; +import org.decepticons.linkshortener.api.service.RevokedTokenCleanupService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +/** + * Service implementation for cleaning up expired revoked tokens. + */ +@Service +public class RevokedTokenCleanupServiceImpl implements + RevokedTokenCleanupService { + + /** + * The repository for managing revoked tokens. + */ + private final RevokedTokenRepository revokedTokenRepository; + + /** + * Constructs a new RevokedTokenCleanupServiceImpl. + * + * @param revokedTokenRepositoryParam The repository for revoked tokens. + */ + public RevokedTokenCleanupServiceImpl( + final RevokedTokenRepository revokedTokenRepositoryParam + ) { + this.revokedTokenRepository = revokedTokenRepositoryParam; + } + + /** + * Deletes all expired revoked tokens. This job runs every hour. + */ + @Override + @Scheduled(cron = "0 0 * * * ?") // every hour + public void cleanupExpiredRevokedTokens() { + revokedTokenRepository.deleteAllByExpiresAtBefore(Instant.now()); + } +} diff --git a/src/main/java/org/decepticons/linkshortener/api/service/impl/UserServiceImpl.java b/src/main/java/org/decepticons/linkshortener/api/service/impl/UserServiceImpl.java index d152524..620e05c 100644 --- a/src/main/java/org/decepticons/linkshortener/api/service/impl/UserServiceImpl.java +++ b/src/main/java/org/decepticons/linkshortener/api/service/impl/UserServiceImpl.java @@ -1,50 +1,65 @@ -package org.decepticons.linkshortener.api.service.impl; + package org.decepticons.linkshortener.api.service.impl; -import java.util.UUID; -import org.decepticons.linkshortener.api.exception.NoSuchUserFoundInTheSystemException; -import org.decepticons.linkshortener.api.model.User; -import org.decepticons.linkshortener.api.repository.UserRepository; -import org.decepticons.linkshortener.api.service.UserService; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; - -/** - * Service implementation for retrieving information about the currently authenticated user. - */ -@Service -public class UserServiceImpl implements UserService { - - public UserRepository userRepository; + import java.util.UUID; + import org.decepticons.linkshortener.api.exception.NoSuchUserFoundInTheSystemException; + import org.decepticons.linkshortener.api.exception.UserNotFoundException; + import org.decepticons.linkshortener.api.model.User; + import org.decepticons.linkshortener.api.repository.UserRepository; + import org.decepticons.linkshortener.api.service.UserService; + import org.springframework.security.core.context.SecurityContextHolder; + import org.springframework.stereotype.Service; /** - * Constructs a new UserServiceImpl with the given UserRepository. - * - * @param userRepository the repository used to access user data + * Service implementation for retrieving information about the currently authenticated user. */ - public UserServiceImpl(UserRepository userRepository) { - this.userRepository = userRepository; - } + @Service + public class UserServiceImpl implements UserService { - @Override - public UUID getCurrentUserId() { - String username = SecurityContextHolder.getContext().getAuthentication().getName(); - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new NoSuchUserFoundInTheSystemException( - "No such user found in the system: " + username, - username - )); - return user.getId(); + final public UserRepository userRepository; - } + /** + * Constructs a new UserServiceImpl with the given UserRepository. + * + * @param userRepository the repository used to access user data + */ + public UserServiceImpl(UserRepository userRepository) { + this.userRepository = userRepository; + } - @Override - public User getCurrentUser() { - String username = SecurityContextHolder.getContext().getAuthentication().getName(); - return userRepository.findByUsername(username) - .orElseThrow(() -> new NoSuchUserFoundInTheSystemException( - "No such user found in the system: " + username, - username - )); - } + @Override + public UUID getCurrentUserId() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new NoSuchUserFoundInTheSystemException( + "No such user found in the system: " + username, + username + )); + return user.getId(); -} + } + + @Override + public User getCurrentUser() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + return userRepository.findByUsername(username) + .orElseThrow(() -> new NoSuchUserFoundInTheSystemException( + "No such user found in the system: " + username, + username + )); + } + + /** + * Finds a user by their username. + * + * @param username the username to search for + * @return the User entity + * @throws UserNotFoundException if no user is found + */ + @Override + public User findByUsername(final String username) { + return userRepository.findByUsername(username) + .orElseThrow(() -> new UserNotFoundException(username) + ); + } + + } diff --git a/src/main/java/org/decepticons/linkshortener/api/service/impl/package-info.java b/src/main/java/org/decepticons/linkshortener/api/service/impl/package-info.java new file mode 100644 index 0000000..b511c10 --- /dev/null +++ b/src/main/java/org/decepticons/linkshortener/api/service/impl/package-info.java @@ -0,0 +1,6 @@ +/** + * Provides the implementation classes for the application's service layer. + * These services contain the core business logic, such as token cleanup + * and user management. + */ +package org.decepticons.linkshortener.api.service.impl; \ No newline at end of file diff --git a/src/main/java/org/decepticons/linkshortener/api/v1/controller/AuthController.java b/src/main/java/org/decepticons/linkshortener/api/v1/controller/AuthController.java index 17593a0..a3c6832 100644 --- a/src/main/java/org/decepticons/linkshortener/api/v1/controller/AuthController.java +++ b/src/main/java/org/decepticons/linkshortener/api/v1/controller/AuthController.java @@ -4,7 +4,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.util.List; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.decepticons.linkshortener.api.dto.AuthRequestDto; import org.decepticons.linkshortener.api.dto.AuthResponseDto; @@ -14,6 +13,7 @@ import org.decepticons.linkshortener.api.security.model.CustomUserDetails; import org.decepticons.linkshortener.api.security.service.AuthService; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -23,7 +23,8 @@ /** * REST controller for user authentication. - * Provides endpoints for user registration, login, and token refresh. + * Provides endpoints for user registration, login, and + * token refresh. */ @Tag( name = "Authentication", @@ -34,10 +35,14 @@ @RequiredArgsConstructor public class AuthController { - /** Service for authentication and user registration operations. */ + /** + * Service for authentication and user registration operations. + */ private final AuthService authService; - /** Utility for generating and validating JWT tokens. */ + /** + * Utility for generating and validating JWT tokens. + */ private final JwtTokenUtil jwtUtil; /** @@ -70,13 +75,16 @@ public ResponseEntity createUser( public ResponseEntity authenticate( @RequestBody final AuthRequestDto request ) { - User user = authService.login(request.getUsername(), request.getPassword()); + User user = authService.login( + request.getUsername(), + request.getPassword() + ); UserDetails userDetails = new CustomUserDetails(user); String accessToken = jwtUtil.generateAccessToken(userDetails); String refreshToken = jwtUtil.generateRefreshToken(userDetails); List roles = userDetails.getAuthorities().stream() - .map(Object::toString) - .collect(Collectors.toList()); + .map(GrantedAuthority::getAuthority) + .toList(); return ResponseEntity.ok(new AuthResponseDto( user.getUsername(), @@ -102,8 +110,8 @@ public ResponseEntity refreshToken( String newAccessToken = jwtUtil.generateAccessToken(userDetails); String newRefreshToken = jwtUtil.generateRefreshToken(userDetails); List roles = userDetails.getAuthorities().stream() - .map(Object::toString) - .collect(Collectors.toList()); + .map(GrantedAuthority::getAuthority) + .toList(); return ResponseEntity.ok(new AuthResponseDto( user.getUsername(), @@ -112,4 +120,20 @@ public ResponseEntity refreshToken( newRefreshToken )); } + + /** + * Logs out the user by revoking their token. + * + * @param authHeader the Authorization header containing + * the token to be revoked + * @return a success message + */ + @PostMapping("/logout") + @Operation(summary = "Logout and revoke token") + public ResponseEntity logout( + @RequestHeader("Authorization") final String authHeader + ) { + authService.logout(authHeader); + return ResponseEntity.ok("Successfully logged out"); + } } diff --git a/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/GlobalExceptionHandlerController.java b/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/GlobalExceptionHandlerController.java index 97f0bb5..7d98e68 100644 --- a/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/GlobalExceptionHandlerController.java +++ b/src/main/java/org/decepticons/linkshortener/api/v1/controller/unversioned/GlobalExceptionHandlerController.java @@ -162,7 +162,7 @@ public ResponseEntity> handleGeneric(final Exception ex) { @ExceptionHandler(NoSuchUserFoundInTheSystemException.class) public ResponseEntity> handleNoSuchUser( - NoSuchUserFoundInTheSystemException ex) { + final NoSuchUserFoundInTheSystemException ex) { return buildErrorResponse( HttpStatus.NOT_FOUND, @@ -184,7 +184,7 @@ public ResponseEntity> handleNoSuchUser( @ExceptionHandler(NoSuchShortLinkFoundInTheSystemException.class) public ResponseEntity> handleNoSuchLink( - NoSuchShortLinkFoundInTheSystemException ex) { + final NoSuchShortLinkFoundInTheSystemException ex) { return buildErrorResponse( HttpStatus.NOT_FOUND, @@ -206,7 +206,7 @@ public ResponseEntity> handleNoSuchLink( @ExceptionHandler(ShortLinkIsOutOfDateException.class) public ResponseEntity> handleShortLinkOutOfDate( - ShortLinkIsOutOfDateException ex) { + final ShortLinkIsOutOfDateException ex) { return buildErrorResponse( HttpStatus.GONE, "Short Link Expired", diff --git a/src/main/resources/db/migration/h2/V4__create_revoked_tokens.sql b/src/main/resources/db/migration/h2/V4__create_revoked_tokens.sql new file mode 100644 index 0000000..004357d --- /dev/null +++ b/src/main/resources/db/migration/h2/V4__create_revoked_tokens.sql @@ -0,0 +1,8 @@ +-- Revoked Tokens table (H2) +create table if not exists revoked_tokens ( + id bigint generated by default as identity primary key, + token varchar(500) not null unique, + expires_at timestamp not null +); + +create index if not exists idx_revoked_tokens_expires_at on revoked_tokens(expires_at); diff --git a/src/main/resources/db/migration/postgresql/V4__create_revoked_tokens.sql b/src/main/resources/db/migration/postgresql/V4__create_revoked_tokens.sql new file mode 100644 index 0000000..1e8a577 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V4__create_revoked_tokens.sql @@ -0,0 +1,8 @@ +-- Revoked Tokens table (PostgreSQL) +create table if not exists revoked_tokens ( + id bigserial primary key, + token varchar(500) not null unique, + expires_at timestamptz not null +); + +create index if not exists idx_revoked_tokens_expires_at on revoked_tokens(expires_at); diff --git a/src/test/java/org/decepticons/linkshortener/api/model/RevokedTokenTest.java b/src/test/java/org/decepticons/linkshortener/api/model/RevokedTokenTest.java new file mode 100644 index 0000000..f541cce --- /dev/null +++ b/src/test/java/org/decepticons/linkshortener/api/model/RevokedTokenTest.java @@ -0,0 +1,45 @@ +package org.decepticons.linkshortener.api.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("RevokedToken Model Tests") +class RevokedTokenTest { + + @Test + @DisplayName("should create RevokedToken with all-args constructor") + void shouldCreateRevokedTokenWithAllArgsConstructor() { + // Given + String tokenValue = "test.token.123"; + Instant expirationTime = Instant.now().plusSeconds(3600); + + // When + RevokedToken revokedToken = new RevokedToken(tokenValue, expirationTime); + + // Then + assertNotNull(revokedToken); + assertEquals(tokenValue, revokedToken.getToken()); + assertEquals(expirationTime, revokedToken.getExpiresAt()); + } + + @Test + @DisplayName("should set and get values correctly with setters and getters") + void shouldSetAndGetValuesCorrectly() { + // Given + RevokedToken revokedToken = new RevokedToken(); + String newTokenValue = "new.test.token.456"; + Instant newExpirationTime = Instant.now().plusSeconds(7200); + + // When + revokedToken.setToken(newTokenValue); + revokedToken.setExpiresAt(newExpirationTime); + + // Then + assertEquals(newTokenValue, revokedToken.getToken()); + assertEquals(newExpirationTime, revokedToken.getExpiresAt()); + } +} \ No newline at end of file diff --git a/src/test/java/org/decepticons/linkshortener/api/security/controller/AuthControllerTest.java b/src/test/java/org/decepticons/linkshortener/api/security/controller/AuthControllerTest.java index 91c83d4..a2a082d 100644 --- a/src/test/java/org/decepticons/linkshortener/api/security/controller/AuthControllerTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/security/controller/AuthControllerTest.java @@ -5,11 +5,15 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.decepticons.linkshortener.api.dto.AuthRequestDto; import org.decepticons.linkshortener.api.dto.RegistrationRequestDto; +import org.decepticons.linkshortener.api.model.RevokedToken; import org.decepticons.linkshortener.api.model.User; +import org.decepticons.linkshortener.api.repository.RevokedTokenRepository; import org.decepticons.linkshortener.api.repository.UserRepository; +import org.decepticons.linkshortener.api.security.jwt.JwtTokenUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -43,6 +47,12 @@ class AuthControllerTest { private WebApplicationContext webApplicationContext; @Autowired private UserRepository userRepository; + @Autowired + private RevokedTokenRepository revokedTokenRepository; + + @Autowired + private JwtTokenUtil jwtUtil; + @Autowired private PasswordEncoder passwordEncoder; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -70,7 +80,8 @@ void setup() { mockMvc = MockMvcBuilders .webAppContextSetup(webApplicationContext) .build(); - userRepository.deleteAll(); // Clean up before each test + userRepository.deleteAll(); + revokedTokenRepository.deleteAll(); } @Test @@ -205,4 +216,72 @@ void givenInvalidRefreshToken_whenRefreshing_thenReturnsUnauthorized() throws Ex .header("Authorization", "Bearer " + invalidToken)) .andExpect(status().isUnauthorized()); } + + @Test + @DisplayName("given a valid token, when logging out, then returns 200 OK") + void givenValidToken_whenLoggingOut_thenReturnsOk() throws Exception { + // 1. Register and log in to get a valid token + RegistrationRequestDto registrationDto = new RegistrationRequestDto(); + registrationDto.setUsername("logoutuser"); + registrationDto.setPassword("Password123!"); + mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registrationDto))) + .andExpect(status().isOk()); + + AuthRequestDto loginDto = new AuthRequestDto(); + loginDto.setUsername("logoutuser"); + loginDto.setPassword("Password123!"); + MvcResult result = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginDto))) + .andExpect(status().isOk()) // Ensure the login is successful + .andReturn(); + JsonNode jsonNode = objectMapper.readTree(result.getResponse().getContentAsString()); + String accessToken = jsonNode.get("accessToken").asText(); + + // 2. Perform a POST request to the logout endpoint + mockMvc.perform(post("/api/v1/auth/logout") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("given a revoked refresh token, when refreshing, then returns 401 Unauthorized") + void givenRevokedRefreshToken_whenRefreshing_thenReturnsUnauthorized() throws Exception { + // 1. Register a new user + RegistrationRequestDto registrationDto = new RegistrationRequestDto(); + registrationDto.setUsername("revokeduser"); + registrationDto.setPassword("Password123!"); + mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registrationDto))) + .andExpect(status().isOk()); + + // 2. Log in to get access and refresh tokens + AuthRequestDto loginDto = new AuthRequestDto(); + loginDto.setUsername("revokeduser"); + loginDto.setPassword("Password123!"); + MvcResult loginResult = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginDto))) + .andExpect(status().isOk()) + .andReturn(); + + JsonNode jsonNode = objectMapper.readTree(loginResult.getResponse().getContentAsString()); + String accessToken = jsonNode.get("accessToken").asText(); + String refreshToken = jsonNode.get("refreshToken").asText(); + + // 3. Revoke the refresh token manually + revokedTokenRepository.save(new RevokedToken( + refreshToken, + jwtUtil.extractExpiration(refreshToken).toInstant() + )); + + // 4. Attempt to refresh with the revoked refresh token + mockMvc.perform(post("/api/v1/auth/refresh") + .header("Authorization", "Bearer " + refreshToken)) + .andExpect(status().isUnauthorized()); + } + } \ No newline at end of file diff --git a/src/test/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilterTest.java b/src/test/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilterTest.java index d3222c8..534b12e 100644 --- a/src/test/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilterTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/security/jwt/JwtAuthenticationFilterTest.java @@ -2,12 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -15,7 +10,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.ArrayList; -import org.decepticons.linkshortener.api.exception.InvalidTokenException; +import org.decepticons.linkshortener.api.repository.RevokedTokenRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -28,124 +23,123 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; -/** - * Unit tests for the JwtAuthenticationFilter. - * These tests focus on the filter's behavior when processing HTTP requests - * with various JWT token scenarios. - */ @ExtendWith(MockitoExtension.class) @DisplayName("JWT Authentication Filter Unit Tests") class JwtAuthenticationFilterTest { - @Mock - private JwtTokenUtil jwtTokenUtil; - - @Mock - private UserDetailsService userDetailsService; - - @Mock - private HttpServletRequest request; - - @Mock - private HttpServletResponse response; - - @Mock - private FilterChain filterChain; - - @InjectMocks - private JwtAuthenticationFilter jwtAuthenticationFilter; - - private final String authHeader - = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0dXNlciJ9.invalid-signature"; - private UserDetails userDetails; - - @BeforeEach - void setUp() { - // Given - SecurityContextHolder.clearContext(); - userDetails = new User("testuser", "password", new ArrayList<>()); - } - - @Test - @DisplayName("given a valid token, when filtering, then authenticates the user successfully") - void givenValidToken_whenFiltering_thenAuthenticatesSuccessfully() - throws ServletException, IOException { - // Given - when(request.getHeader("Authorization")).thenReturn(authHeader); - when(jwtTokenUtil.extractUsername(anyString())).thenReturn("testuser"); - when(userDetailsService.loadUserByUsername("testuser")).thenReturn(userDetails); - when(jwtTokenUtil.validateToken(anyString(), any(UserDetails.class))).thenReturn(true); - - // When - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // Then - assertNotNull(SecurityContextHolder.getContext().getAuthentication()); - verify(filterChain).doFilter(request, response); - } - - @Test - @DisplayName("given no Authorization header, when filtering, then skips authentication") - void givenNoHeader_whenFiltering_thenSkipsAuthentication() throws ServletException, IOException { - // Given - when(request.getHeader("Authorization")).thenReturn(null); - - // When - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // Then - assertNull(SecurityContextHolder.getContext().getAuthentication()); - verify(filterChain).doFilter(request, response); - verify(jwtTokenUtil, never()).extractUsername(anyString()); - } - - @Test - @DisplayName("given a malformed header, when filtering, then throws InvalidTokenException") - void givenMalformedHeader_whenFiltering_thenThrowsInvalidTokenException() - throws ServletException, IOException { - // Given - when(request.getHeader("Authorization")).thenReturn("MalformedToken"); - - // When & Then - assertThrows(InvalidTokenException.class, () -> - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain)); - assertNull(SecurityContextHolder.getContext().getAuthentication()); - verify(filterChain, never()).doFilter(request, response); - verify(jwtTokenUtil, never()).extractUsername(anyString()); - } - - @Test - @DisplayName("given an invalid token, when filtering, then throws InvalidTokenException") - void givenInvalidToken_whenFiltering_thenThrowsInvalidTokenException() - throws ServletException, IOException { - // Given - when(request.getHeader("Authorization")).thenReturn(authHeader); - when(jwtTokenUtil.extractUsername(anyString())).thenReturn("testuser"); - when(userDetailsService.loadUserByUsername("testuser")).thenReturn(userDetails); - when(jwtTokenUtil.validateToken(anyString(), any(UserDetails.class))).thenReturn(false); - - // When & Then - assertThrows(InvalidTokenException.class, () -> - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain)); - assertNull(SecurityContextHolder.getContext().getAuthentication()); - verify(filterChain, never()).doFilter(request, response); - } - - @Test - @DisplayName("given a valid token but a nonexistent user, " - + "when filtering, then skips authentication") - void givenTokenForNonexistentUser_whenFiltering_thenSkipsAuthentication() - throws ServletException, IOException { - // Given - when(request.getHeader("Authorization")).thenReturn(authHeader); - when(jwtTokenUtil.extractUsername(anyString())).thenReturn("nonexistentuser"); - when(userDetailsService.loadUserByUsername("nonexistentuser")).thenReturn(null); - - // When - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // Then - assertNull(SecurityContextHolder.getContext().getAuthentication()); - verify(filterChain).doFilter(request, response); - } -} \ No newline at end of file + @Mock + private JwtTokenUtil jwtTokenUtil; + + @Mock + private UserDetailsService userDetailsService; + + @Mock + private RevokedTokenRepository revokedTokenRepository; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private FilterChain filterChain; + + @InjectMocks + private JwtAuthenticationFilter jwtAuthenticationFilter; + + private final String authHeader + = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0dXNlciJ9.invalid-signature"; + private UserDetails userDetails; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + userDetails = new User("testuser", "password", new ArrayList<>()); + } + + @Test + @DisplayName("given a valid token, when filtering, then authenticates the user successfully") + void givenValidToken_whenFiltering_thenAuthenticatesSuccessfully() + throws ServletException, IOException { + when(request.getHeader("Authorization")).thenReturn(authHeader); + when(jwtTokenUtil.extractUsername(anyString())).thenReturn("testuser"); + when(revokedTokenRepository.existsByToken(anyString())).thenReturn(false); + when(userDetailsService.loadUserByUsername("testuser")).thenReturn(userDetails); + when(jwtTokenUtil.validateToken(anyString(), any(UserDetails.class))).thenReturn(true); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("given a revoked token, when filtering, then skips authentication without calling the filter chain") + void givenRevokedToken_whenFiltering_thenSkipsAuthentication() throws ServletException, IOException { + when(request.getHeader("Authorization")).thenReturn(authHeader); + when(revokedTokenRepository.existsByToken(anyString())).thenReturn(true); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + // Do not verify filterChain.doFilter because it is intentionally NOT called + verify(jwtTokenUtil, never()).extractUsername(anyString()); + } + + @Test + @DisplayName("given no Authorization header, when filtering, then skips authentication") + void givenNoHeader_whenFiltering_thenSkipsAuthentication() + throws ServletException, IOException { + when(request.getHeader("Authorization")).thenReturn(null); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain).doFilter(request, response); + verify(jwtTokenUtil, never()).extractUsername(anyString()); + } + + @Test + @DisplayName("given a malformed header, when filtering, then skips authentication") + void givenMalformedHeader_whenFiltering_thenSkipsAuthentication() + throws ServletException, IOException { + when(request.getHeader("Authorization")).thenReturn("MalformedToken"); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain).doFilter(request, response); + verify(jwtTokenUtil, never()).extractUsername(anyString()); + } + + @Test + @DisplayName("given an invalid token, when filtering, then skips authentication without calling the filter chain") + void givenInvalidToken_whenFiltering_thenSkipsAuthentication() throws ServletException, IOException { + when(request.getHeader("Authorization")).thenReturn(authHeader); + when(revokedTokenRepository.existsByToken(anyString())).thenReturn(false); + when(jwtTokenUtil.extractUsername(anyString())).thenReturn("testuser"); + when(userDetailsService.loadUserByUsername("testuser")).thenReturn(userDetails); + when(jwtTokenUtil.validateToken(anyString(), any(UserDetails.class))).thenReturn(false); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + // Do not verify filterChain.doFilter because it is intentionally NOT called + } + + @Test + @DisplayName("given a valid token but a nonexistent user, when filtering, then skips authentication") + void givenTokenForNonexistentUser_whenFiltering_thenSkipsAuthentication() + throws ServletException, IOException { + when(request.getHeader("Authorization")).thenReturn(authHeader); + when(revokedTokenRepository.existsByToken(anyString())).thenReturn(false); + when(jwtTokenUtil.extractUsername(anyString())).thenReturn("nonexistentuser"); + when(userDetailsService.loadUserByUsername("nonexistentuser")).thenReturn(null); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain).doFilter(request, response); + } +} diff --git a/src/test/java/org/decepticons/linkshortener/api/security/model/CustomUserDetailsTest.java b/src/test/java/org/decepticons/linkshortener/api/security/model/CustomUserDetailsTest.java index e42e9ae..e989d19 100644 --- a/src/test/java/org/decepticons/linkshortener/api/security/model/CustomUserDetailsTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/security/model/CustomUserDetailsTest.java @@ -8,6 +8,7 @@ import java.util.Collections; import org.decepticons.linkshortener.api.model.Role; import org.decepticons.linkshortener.api.model.User; +import org.decepticons.linkshortener.api.model.UserStatus; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -52,12 +53,13 @@ void shouldReturnCorrectAuthorities() { } @Test - @DisplayName("should return true for account and credential status") - void shouldReturnTrueForAccountStatus() { + @DisplayName("should return true for an ACTIVE user") + void shouldReturnTrueForActiveUser() { // Given User user = new User(); user.setUsername("testuser"); user.setPasswordHash("encoded_password"); + user.setStatus(UserStatus.ACTIVE); // When CustomUserDetails userDetails = new CustomUserDetails(user); @@ -68,4 +70,23 @@ void shouldReturnTrueForAccountStatus() { assertTrue(userDetails.isCredentialsNonExpired()); assertTrue(userDetails.isEnabled()); } + + @Test + @DisplayName("should return false for a LOCKED user") + void shouldReturnFalseForLockedUser() { + // Given + User user = new User(); + user.setUsername("lockeduser"); + user.setPasswordHash("encoded_password"); + user.setStatus(UserStatus.LOCKED); + + // When + CustomUserDetails userDetails = new CustomUserDetails(user); + + // Then + assertTrue(userDetails.isAccountNonExpired()); + assertFalse(userDetails.isAccountNonLocked()); + assertTrue(userDetails.isCredentialsNonExpired()); + assertFalse(userDetails.isEnabled()); + } } \ No newline at end of file diff --git a/src/test/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImplTest.java b/src/test/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImplTest.java index 2a94712..e1b72c5 100644 --- a/src/test/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImplTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/security/service/impl/AuthServiceImplTest.java @@ -4,24 +4,26 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; +import java.time.Instant; import java.util.Collections; +import java.util.Date; import java.util.Optional; import org.decepticons.linkshortener.api.exception.InvalidTokenException; import org.decepticons.linkshortener.api.exception.UserAlreadyExistsException; +import org.decepticons.linkshortener.api.exception.UserNotFoundException; +import org.decepticons.linkshortener.api.model.RevokedToken; import org.decepticons.linkshortener.api.model.Role; import org.decepticons.linkshortener.api.model.User; import org.decepticons.linkshortener.api.model.UserStatus; +import org.decepticons.linkshortener.api.repository.RevokedTokenRepository; import org.decepticons.linkshortener.api.repository.RoleRepository; import org.decepticons.linkshortener.api.repository.UserRepository; import org.decepticons.linkshortener.api.security.jwt.JwtTokenUtil; -import org.decepticons.linkshortener.api.security.service.UserAuthService; +import org.decepticons.linkshortener.api.service.UserService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -44,9 +46,6 @@ @DisplayName("Auth Service Unit Tests") class AuthServiceImplTest { - @Mock - private UserAuthService userAuthService; - @Mock private AuthenticationManager authenticationManager; @@ -62,6 +61,12 @@ class AuthServiceImplTest { @Mock private RoleRepository roleRepository; + @Mock + private RevokedTokenRepository revokedTokenRepository; + + @Mock + private UserService userService; + @InjectMocks private AuthServiceImpl authService; @@ -96,7 +101,7 @@ void givenValidCredentials_whenLogin_thenReturnsUser() { Authentication auth = mock(Authentication.class); when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))) .thenReturn(auth); - when(userAuthService.findByUsername("testuser")).thenReturn(testUser); + when(userService.findByUsername("testuser")).thenReturn(testUser); // When User actualUser = authService.login("testuser", "password123"); @@ -105,7 +110,7 @@ void givenValidCredentials_whenLogin_thenReturnsUser() { assertNotNull(actualUser); assertEquals("testuser", actualUser.getUsername()); verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class)); - verify(userAuthService).findByUsername("testuser"); + verify(userService).findByUsername("testuser"); } @Test @@ -119,28 +124,34 @@ void givenInvalidCredentials_whenLogin_thenThrowsBadCredentialsException() { assertThrows(BadCredentialsException.class, () -> authService.login("testuser", "wrongpassword")); verify(authenticationManager).authenticate(any(UsernamePasswordAuthenticationToken.class)); - verify(userAuthService, never()).findByUsername(anyString()); + verify(userService, never()).findByUsername(anyString()); } - @Test - @DisplayName("given a valid refresh token, when refreshing, then returns a User object") - void givenValidRefreshToken_whenRefreshing_thenReturnsUser() { - // Given - String authHeader = "Bearer " + expectedRefreshToken; - when(jwtUtil.extractUsername(expectedRefreshToken)).thenReturn("testuser"); - when(userAuthService.findByUsername("testuser")).thenReturn(testUser); + @Test + @DisplayName("given a valid refresh token, when refreshing, then returns a User object") + void givenValidRefreshToken_whenRefreshing_thenReturnsUser() { + // Given + String authHeader = "Bearer " + expectedRefreshToken; - // When - User actualUser = authService.refreshToken(authHeader); + when(jwtUtil.extractUsername(expectedRefreshToken)).thenReturn("testuser"); + when(userService.findByUsername("testuser")).thenReturn(testUser); - // Then - assertNotNull(actualUser); - assertEquals("testuser", actualUser.getUsername()); - verify(jwtUtil).extractUsername(expectedRefreshToken); - verify(userAuthService).findByUsername("testuser"); - } + // Stub the correct method + doReturn(true).when(jwtUtil).validateToken(anyString()); - @Test + // When + User actualUser = authService.refreshToken(authHeader); + + // Then + assertNotNull(actualUser); + assertEquals("testuser", actualUser.getUsername()); + verify(jwtUtil).extractUsername(expectedRefreshToken); + verify(jwtUtil).validateToken(expectedRefreshToken); + verify(userService).findByUsername("testuser"); + } + + + @Test @DisplayName("given a null or malformed header, " + "when refreshing, then throws InvalidTokenException") void givenMalformedHeader_whenRefreshing_thenThrowsInvalidTokenException() { @@ -153,9 +164,9 @@ void givenMalformedHeader_whenRefreshing_thenThrowsInvalidTokenException() { @DisplayName("given an existing user, when registering, then throws UserAlreadyExistsException") void givenExistingUser_whenRegistering_thenThrowsUserAlreadyExistsException() { // Given + when(userRepository.existsByUsername("testuser")).thenReturn(true); when(roleRepository.findByName("ROLE_USER")).thenReturn(Optional.of(userRole)); - when(userAuthService.registerUser(any(User.class))) - .thenThrow(new UserAlreadyExistsException("testuser")); + testUser.setPasswordHash("Password123!"); // When & Then assertThrows(UserAlreadyExistsException.class, () -> authService.registerUser(testUser)); @@ -166,8 +177,10 @@ void givenExistingUser_whenRegistering_thenThrowsUserAlreadyExistsException() { void givenNewUser_whenRegistering_thenReturnsUser() { // Given when(roleRepository.findByName("ROLE_USER")).thenReturn(Optional.of(userRole)); - // The stubbing is needed to ensure that userAuthService returns the mocked user object. - when(userAuthService.registerUser(any(User.class))).thenReturn(testUser); + when(userRepository.existsByUsername(anyString())).thenReturn(false); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + when(userRepository.save(any(User.class))).thenReturn(testUser); + testUser.setPasswordHash("Password123!"); // When User registeredUser = authService.registerUser(testUser); @@ -177,6 +190,33 @@ void givenNewUser_whenRegistering_thenReturnsUser() { assertEquals("testuser", registeredUser.getUsername()); assertEquals(UserStatus.ACTIVE, registeredUser.getStatus()); assertFalse(registeredUser.getRoles().isEmpty()); - verify(userAuthService).registerUser(any(User.class)); + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("given a valid token, when logout, then revokes the token") + void givenValidToken_whenLogout_thenRevokesToken() { + // Given + String validToken = "Bearer valid.token.string"; + String jwtToken = "valid.token.string"; + Date expirationDate = new Date(); + Instant expirationInstant = expirationDate.toInstant(); + + when(jwtUtil.extractExpiration(jwtToken)).thenReturn(expirationDate); + when(revokedTokenRepository.save(any(RevokedToken.class))).thenReturn(new RevokedToken()); + + // When + authService.logout(validToken); + + // Then + verify(revokedTokenRepository).save(any(RevokedToken.class)); + } + + @Test + @DisplayName("given a null or malformed token, when logout, then throws InvalidTokenException") + void givenMalformedToken_whenLogout_thenThrowsInvalidTokenException() { + // When & Then + assertThrows(InvalidTokenException.class, () -> authService.logout(null)); + assertThrows(InvalidTokenException.class, () -> authService.logout("InvalidToken")); } } \ No newline at end of file diff --git a/src/test/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImplTest.java b/src/test/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImplTest.java deleted file mode 100644 index f142b4d..0000000 --- a/src/test/java/org/decepticons/linkshortener/api/security/service/impl/UserAuthServiceImplTest.java +++ /dev/null @@ -1,132 +0,0 @@ -package org.decepticons.linkshortener.api.security.service.impl; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import org.decepticons.linkshortener.api.exception.InvalidPasswordException; -import org.decepticons.linkshortener.api.exception.UserAlreadyExistsException; -import org.decepticons.linkshortener.api.exception.UserNotFoundException; -import org.decepticons.linkshortener.api.model.User; -import org.decepticons.linkshortener.api.repository.UserRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.password.PasswordEncoder; - -/** - * Unit tests for the UserAuthServiceImpl class. - * These tests focus on the user registration and retrieval logic using mocks for dependencies. - */ -@ExtendWith(MockitoExtension.class) -@DisplayName("User Authentication Service Unit Tests") -class UserAuthServiceImplTest { - - @Mock - private UserRepository userRepository; - - @Mock - private PasswordEncoder passwordEncoder; - - @InjectMocks - private UserAuthServiceImpl userAuthService; - - private User validUser; - private User invalidPasswordUser; - private String rawPassword; - private String invalidPassword; - - @BeforeEach - void setUp() { - rawPassword = "Password123"; - invalidPassword = "short"; - - validUser = new User(); - validUser.setUsername("testuser"); - validUser.setPasswordHash(rawPassword); - - invalidPasswordUser = new User(); - invalidPasswordUser.setUsername("invalidpassworduser"); - invalidPasswordUser.setPasswordHash(invalidPassword); - } - - @Test - @DisplayName("given a new user, when registering, then saves the user successfully") - void givenNewUser_whenRegistering_thenSavesUserSuccessfully() { - // Given - when(userRepository.existsByUsername(anyString())).thenReturn(false); - when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); - when(userRepository.save(any(User.class))).thenReturn(validUser); - - // When - User actualUser = userAuthService.registerUser(validUser); - - // Then - assertNotNull(actualUser); - assertEquals("testuser", actualUser.getUsername()); - verify(userRepository).existsByUsername("testuser"); - verify(passwordEncoder).encode(rawPassword); - verify(userRepository).save(any(User.class)); - } - - @Test - @DisplayName("given an existing user, when registering, then throws UserAlreadyExistsException") - void givenExistingUser_whenRegistering_thenThrowsUserAlreadyExistsException() { - // Given - when(userRepository.existsByUsername(anyString())).thenReturn(true); - - // When & Then - assertThrows(UserAlreadyExistsException.class, () -> userAuthService.registerUser(validUser)); - verify(userRepository, never()).save(any(User.class)); - } - - @Test - @DisplayName("given an invalid password, when registering, then throws InvalidPasswordException") - void givenInvalidPassword_whenRegistering_thenThrowsInvalidPasswordException() { - // Given - when(userRepository.existsByUsername(anyString())).thenReturn(false); - - // When & Then - assertThrows(InvalidPasswordException.class, - () -> userAuthService.registerUser(invalidPasswordUser)); - verify(userRepository, never()).save(any(User.class)); - } - - @Test - @DisplayName("given an existing username, when finding by username, then returns the user") - void givenExistingUsername_whenFindingByUsername_thenReturnsUser() { - // Given - when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(validUser)); - - // When - User actualUser = userAuthService.findByUsername("testuser"); - - // Then - assertNotNull(actualUser); - assertEquals("testuser", actualUser.getUsername()); - verify(userRepository).findByUsername("testuser"); - } - - @Test - @DisplayName("given a nonexistent username, " - + "when finding by username, then throws UserNotFoundException") - void givenNonexistentUsername_whenFindingByUsername_thenThrowsUserNotFoundException() { - // Given - when(userRepository.findByUsername("nonexistentuser")).thenReturn(Optional.empty()); - - // When & Then - assertThrows(UserNotFoundException.class, - () -> userAuthService.findByUsername("nonexistentuser")); - verify(userRepository).findByUsername("nonexistentuser"); - } -} \ No newline at end of file diff --git a/src/test/java/org/decepticons/linkshortener/api/service/RevokedTokenCleanupServiceImplTest.java b/src/test/java/org/decepticons/linkshortener/api/service/RevokedTokenCleanupServiceImplTest.java new file mode 100644 index 0000000..f5371e0 --- /dev/null +++ b/src/test/java/org/decepticons/linkshortener/api/service/RevokedTokenCleanupServiceImplTest.java @@ -0,0 +1,35 @@ +package org.decepticons.linkshortener.api.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +import java.time.Instant; +import org.decepticons.linkshortener.api.repository.RevokedTokenRepository; +import org.decepticons.linkshortener.api.service.impl.RevokedTokenCleanupServiceImpl; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RevokedTokenCleanupService Unit Tests") +class RevokedTokenCleanupServiceImplTest { + + @Mock + private RevokedTokenRepository revokedTokenRepository; + + @InjectMocks + private RevokedTokenCleanupServiceImpl revokedTokenCleanupService; + + @Test + @DisplayName("should call repository to delete expired tokens") + void shouldCallRepositoryToDeleteExpiredTokens() { + // When the scheduled cleanup method is called + revokedTokenCleanupService.cleanupExpiredRevokedTokens(); + + // Then the deleteAllByExpiresAtBefore method should be called on the repository + verify(revokedTokenRepository).deleteAllByExpiresAtBefore(any(Instant.class)); + } +} \ No newline at end of file diff --git a/src/test/java/org/decepticons/linkshortener/api/service/UserServiceImplTest.java b/src/test/java/org/decepticons/linkshortener/api/service/UserServiceImplTest.java index a0cb40f..cb32f9c 100644 --- a/src/test/java/org/decepticons/linkshortener/api/service/UserServiceImplTest.java +++ b/src/test/java/org/decepticons/linkshortener/api/service/UserServiceImplTest.java @@ -4,16 +4,16 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.Optional; import java.util.UUID; import org.decepticons.linkshortener.api.exception.NoSuchUserFoundInTheSystemException; +import org.decepticons.linkshortener.api.exception.UserNotFoundException; import org.decepticons.linkshortener.api.model.User; import org.decepticons.linkshortener.api.repository.UserRepository; import org.decepticons.linkshortener.api.service.impl.UserServiceImpl; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -36,56 +36,98 @@ public class UserServiceImplTest { @InjectMocks private UserServiceImpl userService; - @Mock - private SecurityContext securityContext; - - @Mock - private Authentication authentication; - - @BeforeEach - void setUp() { + @Test + @DisplayName("Test getCurrentUser returns correct User") + void testGetCurrentUserSuccess() { + SecurityContext securityContext = mock(SecurityContext.class); + Authentication authentication = mock(Authentication.class); when(authentication.getName()).thenReturn("testuser"); when(securityContext.getAuthentication()).thenReturn(authentication); org.springframework.security.core.context.SecurityContextHolder.setContext(securityContext); - } - - @AfterEach - void tearDown() { - org.springframework.security.core.context.SecurityContextHolder.clearContext(); - } - @Test - @DisplayName("Test getCurrentUser returns correct User") - void testGetCurrentUserSuccess() { User fakeUser = new User(); fakeUser.setUsername("testuser"); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(fakeUser)); + User result = userService.getCurrentUser(); + assertNotNull(result); assertEquals("testuser", result.getUsername()); + + org.springframework.security.core.context.SecurityContextHolder.clearContext(); } @Test @DisplayName("Test getCurrentUserId returns correct UUID") void testGetCurrentUserIdSuccess() { + SecurityContext securityContext = mock(SecurityContext.class); + Authentication authentication = mock(Authentication.class); + when(authentication.getName()).thenReturn("testuser"); + when(securityContext.getAuthentication()).thenReturn(authentication); + org.springframework.security.core.context.SecurityContextHolder.setContext(securityContext); + UUID fakeId = UUID.randomUUID(); User fakeUser = new User(); ReflectionTestUtils.setField(fakeUser, "id", fakeId); fakeUser.setUsername("testuser"); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(fakeUser)); + UUID result = userService.getCurrentUserId(); + assertEquals(fakeId, result); + + org.springframework.security.core.context.SecurityContextHolder.clearContext(); } @Test @DisplayName("Test getCurrentUser throws exception when user not found") void testGetCurrentUserUserNotFound() { + SecurityContext securityContext = mock(SecurityContext.class); + Authentication authentication = mock(Authentication.class); + when(authentication.getName()).thenReturn("testuser"); + when(securityContext.getAuthentication()).thenReturn(authentication); + org.springframework.security.core.context.SecurityContextHolder.setContext(securityContext); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.empty()); + NoSuchUserFoundInTheSystemException ex = assertThrows( NoSuchUserFoundInTheSystemException.class, () -> userService.getCurrentUser() ); + assertTrue(ex.getMessage().contains("No such user found in the system")); + + org.springframework.security.core.context.SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("Test findByUsername returns a user when found") + void testFindByUsernameSuccess() { + // Given + String username = "testuser"; + User expectedUser = new User(); + expectedUser.setUsername(username); + when(userRepository.findByUsername(username)).thenReturn(Optional.of(expectedUser)); + + // When + User actualUser = userService.findByUsername(username); + + // Then + assertNotNull(actualUser); + assertEquals(username, actualUser.getUsername()); + } + + @Test + @DisplayName("Test findByUsername throws an exception when user is not found") + void testFindByUsernameNotFound() { + // Given + String username = "nonexistentuser"; + when(userRepository.findByUsername(username)).thenReturn(Optional.empty()); + + // When & Then + assertThrows(UserNotFoundException.class, () -> userService.findByUsername(username)); } }