diff --git a/backend/pcmod/pom.xml b/backend/pcmod/pom.xml index ac5a7e0..34eccf0 100644 --- a/backend/pcmod/pom.xml +++ b/backend/pcmod/pom.xml @@ -19,6 +19,7 @@ 25 1.21.4 + 0.12.6 @@ -44,6 +45,26 @@ spring-boot-starter-security + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + com.mysql mysql-connector-j diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/UsersRestController.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/UsersRestController.java new file mode 100644 index 0000000..e5def16 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/UsersRestController.java @@ -0,0 +1,43 @@ +package es.codeurjcstudents.pcmod.controller; + +import java.security.Principal; +import java.util.NoSuchElementException; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import es.codeurjcstudents.pcmod.dto.UserDTO; +import es.codeurjcstudents.pcmod.dto.UserMapper; +import es.codeurjcstudents.pcmod.model.User; +import es.codeurjcstudents.pcmod.service.UsersService; +import jakarta.servlet.http.HttpServletRequest; + +@RestController +@RequestMapping("/api/v1/users") +public class UsersRestController { + + @Autowired + UsersService usersService; + + @Autowired + UserMapper userMapper; + + @GetMapping("/me") + public UserDTO me(HttpServletRequest request) { + + Principal principal = request.getUserPrincipal(); + + if (principal != null) { + + User user = usersService.getUser(Long.parseLong(principal.getName())).orElseThrow(); + return userMapper.toDTO(user); + + } else { + + throw new NoSuchElementException(); + + } + } +} diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/auth/LoginRestController.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/auth/LoginRestController.java new file mode 100644 index 0000000..5255eb4 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/auth/LoginRestController.java @@ -0,0 +1,43 @@ +package es.codeurjcstudents.pcmod.controller.auth; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import es.codeurjcstudents.pcmod.security.jwt.AuthResponse; +import es.codeurjcstudents.pcmod.security.jwt.AuthResponse.Status; +import es.codeurjcstudents.pcmod.security.jwt.LoginRequest; +import es.codeurjcstudents.pcmod.security.jwt.UserLoginService; +import jakarta.servlet.http.HttpServletResponse; + +@RestController +@RequestMapping("/api/v1/auth") +public class LoginRestController { + + @Autowired + private UserLoginService userService; + + @PostMapping("/login") + public ResponseEntity login( + @RequestBody LoginRequest loginRequest, + HttpServletResponse response) { + + return userService.login(response, loginRequest); + } + + @PostMapping("/refresh") + public ResponseEntity refreshToken( + @CookieValue(name = "RefreshToken", required = false) String refreshToken, HttpServletResponse response) { + + return userService.refresh(response, refreshToken); + } + + @PostMapping("/logout") + public ResponseEntity logOut(HttpServletResponse response) { + return ResponseEntity.ok(new AuthResponse(Status.SUCCESS, userService.logout(response))); + } +} \ No newline at end of file diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/UserDTO.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/UserDTO.java new file mode 100644 index 0000000..283443f --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/UserDTO.java @@ -0,0 +1,13 @@ +package es.codeurjcstudents.pcmod.dto; + +import java.util.List; + +public record UserDTO( + Long id, + String name, + String surname, + String username, + String address, + String email, + List roles) { +} \ No newline at end of file diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/UserMapper.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/UserMapper.java new file mode 100644 index 0000000..509ea7d --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/UserMapper.java @@ -0,0 +1,12 @@ +package es.codeurjcstudents.pcmod.dto; + +import org.mapstruct.Mapper; + +import es.codeurjcstudents.pcmod.model.User; + +@Mapper(componentModel = "spring") +public interface UserMapper { + + UserDTO toDTO(User user); + +} \ No newline at end of file diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/User.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/User.java new file mode 100644 index 0000000..7d31a86 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/User.java @@ -0,0 +1,125 @@ +package es.codeurjcstudents.pcmod.model; + +import java.util.List; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; + +@Entity(name = "UserTable") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + private String name; + + private String surname; + + private String username; + + private String email; + + private String encodedPassword; + + private String address; + + @OneToOne(cascade = CascadeType.ALL) + private Image image; + + @ElementCollection(fetch = FetchType.EAGER) + private List roles; + + public User() { + } + + public User(String name, String surname, String username, String address, String email, String encodedPassword, + String... roles) { + this.name = name; + this.surname = surname; + this.username = username; + this.address = address; + this.email = email; + this.encodedPassword = encodedPassword; + this.roles = List.of(roles); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSurname() { + return surname; + } + + public void setSurname(String surname) { + this.surname = surname; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getEncodedPassword() { + return encodedPassword; + } + + public void setEncodedPassword(String encodedPassword) { + this.encodedPassword = encodedPassword; + } + + public Image getImage() { + return image; + } + + public void setImage(Image image) { + this.image = image; + } + + public List getRoles() { + return roles; + } + + public void setRoles(List roles) { + this.roles = roles; + } + +} \ No newline at end of file diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/UsersRepository.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/UsersRepository.java new file mode 100644 index 0000000..3e6c942 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/UsersRepository.java @@ -0,0 +1,13 @@ +package es.codeurjcstudents.pcmod.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import es.codeurjcstudents.pcmod.model.User; + +public interface UsersRepository extends JpaRepository { + + Optional findByEmail(String email); + +} diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/CSRFHandlerConfiguration.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/CSRFHandlerConfiguration.java new file mode 100644 index 0000000..a3f66c6 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/CSRFHandlerConfiguration.java @@ -0,0 +1,36 @@ +package es.codeurjcstudents.pcmod.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CSRFHandlerConfiguration implements WebMvcConfigurer { + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new CSRFHandlerInterceptor()); + } +} + +class CSRFHandlerInterceptor implements HandlerInterceptor { + + @Override + public void postHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler, + final ModelAndView modelAndView) throws Exception { + + if (modelAndView != null) { + + CsrfToken token = (CsrfToken) request.getAttribute("_csrf"); + if (token != null) { + modelAndView.addObject("token", token.getToken()); + } + } + } +} diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/RepositoryUserDetailsService.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/RepositoryUserDetailsService.java new file mode 100644 index 0000000..e4a1689 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/RepositoryUserDetailsService.java @@ -0,0 +1,39 @@ +package es.codeurjcstudents.pcmod.security; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import es.codeurjcstudents.pcmod.model.User; +import es.codeurjcstudents.pcmod.repository.UsersRepository; + +@Service +public class RepositoryUserDetailsService implements UserDetailsService { + + @Autowired + private UsersRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String identifier) throws UsernameNotFoundException { + + User user = userRepository.findByEmail(identifier) + .orElseGet(() -> userRepository.findById(Long.parseLong(identifier)) + .orElseThrow(() -> new UsernameNotFoundException("User not found with id or email: " + identifier))); + + List roles = new ArrayList<>(); + for (String role : user.getRoles()) { + roles.add(new SimpleGrantedAuthority("ROLE_" + role)); + } + + return new org.springframework.security.core.userdetails.User(user.getId().toString(), user.getEncodedPassword(), + roles); + + } +} diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/SecurityConfig.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/SecurityConfig.java index 3ee6a78..ea3eb36 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/SecurityConfig.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/SecurityConfig.java @@ -1,23 +1,68 @@ package es.codeurjcstudents.pcmod.security; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import es.codeurjcstudents.pcmod.security.jwt.JwtRequestFilter; +import es.codeurjcstudents.pcmod.security.jwt.JwtTokenProvider; +import es.codeurjcstudents.pcmod.security.jwt.UnauthorizedHandlerJwt; @Configuration public class SecurityConfig { + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + public RepositoryUserDetailsService userDetailService; + + @Autowired + private UnauthorizedHandlerJwt unauthorizedHandlerJwt; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailService); + authProvider.setPasswordEncoder(passwordEncoder()); + + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } + @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.authenticationProvider(authenticationProvider()); + + http.securityMatcher("/api/**") + .exceptionHandling(handling -> handling.authenticationEntryPoint(unauthorizedHandlerJwt)); + http.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()); http.formLogin(formLogin -> formLogin.disable()); http.csrf(csrf -> csrf.disable()); http.httpBasic(httpBasic -> httpBasic.disable()); http.sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + http.addFilterBefore(new JwtRequestFilter(userDetailService, jwtTokenProvider), + UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/AuthResponse.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/AuthResponse.java new file mode 100644 index 0000000..827616c --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/AuthResponse.java @@ -0,0 +1,56 @@ +package es.codeurjcstudents.pcmod.security.jwt; + +public class AuthResponse { + + private Status status; + private String message; + private String error; + + public enum Status { + SUCCESS, FAILURE + } + + public AuthResponse() { + } + + public AuthResponse(Status status, String message) { + this.status = status; + this.message = message; + } + + public AuthResponse(Status status, String message, String error) { + this.status = status; + this.message = message; + this.error = error; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + @Override + public String toString() { + return "LoginResponse [status=" + status + ", message=" + message + ", error=" + error + "]"; + } + +} diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/JwtRequestFilter.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/JwtRequestFilter.java new file mode 100644 index 0000000..4a4b908 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/JwtRequestFilter.java @@ -0,0 +1,56 @@ +package es.codeurjcstudents.pcmod.security.jwt; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class JwtRequestFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(JwtRequestFilter.class); + + private final UserDetailsService userDetailsService; + + private final JwtTokenProvider jwtTokenProvider; + + public JwtRequestFilter(UserDetailsService userDetailsService, JwtTokenProvider jwtTokenProvider) { + this.userDetailsService = userDetailsService; + this.jwtTokenProvider = jwtTokenProvider; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + try { + var claims = jwtTokenProvider.validateToken(request, true); + var userDetails = userDetailsService.loadUserByUsername(claims.getSubject()); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception ex) { + // Avoid logging when no token is found + if (!ex.getMessage().equals("No access token cookie found in request")) { + log.error("Exception processing JWT Token: ", ex); + } + } + + filterChain.doFilter(request, response); + } + +} diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/JwtTokenProvider.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..5ae045b --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/JwtTokenProvider.java @@ -0,0 +1,83 @@ +package es.codeurjcstudents.pcmod.security.jwt; + +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; + +@Component +public class JwtTokenProvider { + + private final SecretKey jwtSecret = Jwts.SIG.HS256.key().build(); + private final JwtParser jwtParser = Jwts.parser().verifyWith(jwtSecret).build(); + + public String tokenStringFromHeaders(HttpServletRequest req) { + String bearerToken = req.getHeader(HttpHeaders.AUTHORIZATION); + if (bearerToken == null) { + throw new IllegalArgumentException("Missing Authorization header"); + } + if (!bearerToken.startsWith("Bearer ")) { + throw new IllegalArgumentException("Authorization header does not start with Bearer: " + bearerToken); + } + return bearerToken.substring(7); + } + + private String tokenStringFromCookies(HttpServletRequest request) { + var cookies = request.getCookies(); + if (cookies == null) { + throw new IllegalArgumentException("No cookies found in request"); + } + + for (Cookie cookie : cookies) { + if (TokenType.ACCESS.cookieName.equals(cookie.getName())) { + String accessToken = cookie.getValue(); + if (accessToken == null) { + throw new IllegalArgumentException("Cookie %s has null value".formatted(TokenType.ACCESS.cookieName)); + } + + return accessToken; + } + } + throw new IllegalArgumentException("No access token cookie found in request"); + } + + public Claims validateToken(HttpServletRequest req, boolean fromCookie) { + var token = fromCookie ? tokenStringFromCookies(req) : tokenStringFromHeaders(req); + return validateToken(token); + } + + public Claims validateToken(String token) { + return jwtParser.parseSignedClaims(token).getPayload(); + } + + public String generateAccessToken(UserDetails userDetails) { + return buildToken(TokenType.ACCESS, userDetails).compact(); + } + + public String generateRefreshToken(UserDetails userDetails) { + var token = buildToken(TokenType.REFRESH, userDetails); + return token.compact(); + } + + private JwtBuilder buildToken(TokenType tokenType, UserDetails userDetails) { + var currentDate = new Date(); + var expiryDate = Date.from(new Date().toInstant().plus(tokenType.duration)); + return Jwts.builder() + .claim("roles", userDetails.getAuthorities()) + .claim("type", tokenType.name()) + .subject(userDetails.getUsername()) + .issuedAt(currentDate) + .expiration(expiryDate) + .signWith(jwtSecret); + } +} diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/LoginRequest.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/LoginRequest.java new file mode 100644 index 0000000..8ec3c52 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/LoginRequest.java @@ -0,0 +1,36 @@ +package es.codeurjcstudents.pcmod.security.jwt; + +public class LoginRequest { + + private String username; + private String password; + + public LoginRequest() { + } + + public LoginRequest(String username, String password) { + this.username = username; + this.password = password; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + @Override + public String toString() { + return "LoginRequest [username=" + username + ", password=" + password + "]"; + } +} diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/TokenType.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/TokenType.java new file mode 100644 index 0000000..03f34f8 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/TokenType.java @@ -0,0 +1,20 @@ +package es.codeurjcstudents.pcmod.security.jwt; + +import java.time.Duration; + +public enum TokenType { + + ACCESS(Duration.ofMinutes(5), "AuthToken"), + REFRESH(Duration.ofDays(7), "RefreshToken"); + + /** + * Token lifetime in seconds + */ + public final Duration duration; + public final String cookieName; + + TokenType(Duration duration, String cookieName) { + this.duration = duration; + this.cookieName = cookieName; + } +} \ No newline at end of file diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/UnauthorizedHandlerJwt.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/UnauthorizedHandlerJwt.java new file mode 100644 index 0000000..3464837 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/UnauthorizedHandlerJwt.java @@ -0,0 +1,27 @@ +package es.codeurjcstudents.pcmod.security.jwt; + +import java.io.IOException; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class UnauthorizedHandlerJwt implements AuthenticationEntryPoint { + + private static final Logger logger = LoggerFactory.getLogger(UnauthorizedHandlerJwt.class); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) + throws IOException { + logger.info("Unauthorized error: {}", authException.getMessage()); + + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, + "message: %s, path: %s".formatted(authException.getMessage(), request.getServletPath())); + } +} diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/UserLoginService.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/UserLoginService.java new file mode 100644 index 0000000..b848f4c --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/UserLoginService.java @@ -0,0 +1,99 @@ +package es.codeurjcstudents.pcmod.security.jwt; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +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.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Service; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; + +@Service +public class UserLoginService { + + private static final Logger log = LoggerFactory.getLogger(UserLoginService.class); + + private final AuthenticationManager authenticationManager; + private final UserDetailsService userDetailsService; + private final JwtTokenProvider jwtTokenProvider; + + public UserLoginService(AuthenticationManager authenticationManager, UserDetailsService userDetailsService, + JwtTokenProvider jwtTokenProvider) { + this.authenticationManager = authenticationManager; + this.userDetailsService = userDetailsService; + this.jwtTokenProvider = jwtTokenProvider; + } + + public ResponseEntity login(HttpServletResponse response, LoginRequest loginRequest) { + + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword())); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + String username = loginRequest.getUsername(); + UserDetails user = userDetailsService.loadUserByUsername(username); + + HttpHeaders responseHeaders = new HttpHeaders(); + var newAccessToken = jwtTokenProvider.generateAccessToken(user); + var newRefreshToken = jwtTokenProvider.generateRefreshToken(user); + + response.addCookie(buildTokenCookie(TokenType.ACCESS, newAccessToken)); + response.addCookie(buildTokenCookie(TokenType.REFRESH, newRefreshToken)); + + AuthResponse loginResponse = new AuthResponse(AuthResponse.Status.SUCCESS, + "Auth successful. Tokens are created in cookie."); + return ResponseEntity.ok().headers(responseHeaders).body(loginResponse); + } + + public ResponseEntity refresh(HttpServletResponse response, String refreshToken) { + try { + var claims = jwtTokenProvider.validateToken(refreshToken); + UserDetails user = userDetailsService.loadUserByUsername(claims.getSubject()); + + var newAccessToken = jwtTokenProvider.generateAccessToken(user); + response.addCookie(buildTokenCookie(TokenType.ACCESS, newAccessToken)); + + AuthResponse loginResponse = new AuthResponse(AuthResponse.Status.SUCCESS, + "Auth successful. Tokens are created in cookie."); + return ResponseEntity.ok().body(loginResponse); + + } catch (Exception e) { + log.error("Error while processing refresh token", e); + AuthResponse loginResponse = new AuthResponse(AuthResponse.Status.FAILURE, + "Failure while processing refresh token"); + return ResponseEntity.ok().body(loginResponse); + } + } + + public String logout(HttpServletResponse response) { + SecurityContextHolder.clearContext(); + response.addCookie(removeTokenCookie(TokenType.ACCESS)); + response.addCookie(removeTokenCookie(TokenType.REFRESH)); + + return "Logout successfully"; + } + + private Cookie buildTokenCookie(TokenType type, String token) { + Cookie cookie = new Cookie(type.cookieName, token); + cookie.setMaxAge((int) type.duration.getSeconds()); + cookie.setHttpOnly(true); + cookie.setPath("/"); + return cookie; + } + + private Cookie removeTokenCookie(TokenType type) { + Cookie cookie = new Cookie(type.cookieName, ""); + cookie.setMaxAge(0); + cookie.setHttpOnly(true); + cookie.setPath("/"); + return cookie; + } +} diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/DatabaseInitializer.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/DatabaseInitializer.java index 3024fac..77c6135 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/DatabaseInitializer.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/DatabaseInitializer.java @@ -3,19 +3,30 @@ import java.math.BigDecimal; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import es.codeurjcstudents.pcmod.enums.ComponentType; import es.codeurjcstudents.pcmod.model.Component; +import es.codeurjcstudents.pcmod.model.User; import es.codeurjcstudents.pcmod.repository.ComponentsRepository; import jakarta.annotation.PostConstruct; @Service public class DatabaseInitializer { + @Value("${app.user-password}") + private String userPasswordEncoded; + + @Value("${app.admin-password}") + private String adminPasswordEncoded; + @Autowired private ComponentsRepository componentsRepository; + @Autowired + private UsersService userService; + @Autowired private ImageService imageService; @@ -127,6 +138,12 @@ public void init() { "Velocidad de memoria del reloj: 6000 MHz", ComponentType.RAM, "Kingston", BigDecimal.valueOf(269.00), 15)); + userService.createUser(new User("user", "example", "user_example", "c/example_address 1", "user@example.com", + userPasswordEncoded, "REGISTERED_USER")); + userService.createUser( + new User("admin", "example", "admin_example", "c/example_address 5", "admin@example.com", adminPasswordEncoded, + "REGISTERED_USER", "ADMIN")); + } } diff --git a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/UsersService.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/UsersService.java new file mode 100644 index 0000000..80a0ebd --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/UsersService.java @@ -0,0 +1,27 @@ +package es.codeurjcstudents.pcmod.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import es.codeurjcstudents.pcmod.model.User; +import es.codeurjcstudents.pcmod.repository.UsersRepository; + +@Service +public class UsersService { + + private final UsersRepository userRepository; + + public UsersService(UsersRepository userRepository) { + this.userRepository = userRepository; + } + + public User createUser(User user) { + return userRepository.save(user); + } + + public Optional getUser(long id) { + return userRepository.findById(id); + } + +} diff --git a/backend/pcmod/src/main/resources/application.properties b/backend/pcmod/src/main/resources/application.properties index 72145b3..01daf39 100644 --- a/backend/pcmod/src/main/resources/application.properties +++ b/backend/pcmod/src/main/resources/application.properties @@ -12,6 +12,9 @@ spring.datasource.username=${DB_USERNAME:root} spring.datasource.password=${DB_PASSWORD:password} spring.jpa.hibernate.ddl-auto=${DB_MODE:create-drop} +app.admin-password=${APP_ADMIN_PASSWORD:$2a$10$DuASUp2a9b5lBY7hNSn3Xua1NQHrS9U5P5ZUGcWpRC15oCnMLk3fu} +app.user-password=${APP_USER_PASSWORD:$2a$10$PUUXtWmjzWTsCPTMX22PuOb7PpbLxsRRmzwBrWBciF.GVEtKDRmdW} + spring.servlet.encoding.charset=UTF-8 spring.servlet.encoding.enabled=true spring.servlet.encoding.force=true diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/UsersSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/UsersSystemTests.java new file mode 100644 index 0000000..5ac4c4e --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/UsersSystemTests.java @@ -0,0 +1,133 @@ +package es.codeurjcstudents.pcmod.e2e.api; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +@Tag("server-system") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class UsersSystemTests { + + @LocalServerPort + private int port; + + @BeforeEach + public void setup() { + RestAssured.port = port; + RestAssured.baseURI = "https://localhost"; + RestAssured.basePath = "/api/v1/"; + RestAssured.useRelaxedHTTPSValidation(); + } + + @Test + public void login() { + given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "user@example.com", + "password": "userpass" + } + """) + .when().post("auth/login") + .then().statusCode(200).contentType(ContentType.JSON) + .body("error", nullValue()) + .body("message", equalTo("Auth successful. Tokens are created in cookie.")) + .body("status", equalTo("SUCCESS")) + .cookie("AuthToken", notNullValue()) + .cookie("RefreshToken", notNullValue()); + } + + @Test + public void loginError() { + given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "user@example.com", + "password": "pass" + } + """) + .when().post("auth/login") + .then().statusCode(401).contentType(ContentType.JSON) + .body("error", equalTo("Unauthorized")) + .body("status", equalTo(401)) + .body("message", nullValue()); + } + + @Test + public void refresh() { + String refreshToken = given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "user@example.com", + "password": "userpass" + } + """) + .when().post("auth/login") + .then().statusCode(200) + .extract().cookie("RefreshToken"); + + given() + .cookie("RefreshToken", refreshToken) + .when().post("auth/refresh") + .then().statusCode(200).contentType(ContentType.JSON) + .body("error", nullValue()) + .body("message", equalTo("Auth successful. Tokens are created in cookie.")) + .body("status", equalTo("SUCCESS")); + } + + @Test + public void logout() { + given().header("Content-Type", "application/json") + .when().post("auth/logout") + .then().statusCode(200).contentType(ContentType.JSON) + .body("error", nullValue()) + .body("message", equalTo("Logout successfully")) + .body("status", equalTo("SUCCESS")) + .cookie("AuthToken", emptyString()) + .cookie("RefreshToken", emptyString()); + } + + @Test + public void getMe() { + String authToken = given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "user@example.com", + "password": "userpass" + } + """) + .when().post("auth/login") + .then().statusCode(200).contentType(ContentType.JSON) + .body("error", nullValue()) + .body("message", equalTo("Auth successful. Tokens are created in cookie.")) + .body("status", equalTo("SUCCESS")) + .cookie("AuthToken", notNullValue()) + .cookie("RefreshToken", notNullValue()) + .extract().cookie("AuthToken"); + + given() + .header("Content-Type", "application/json") + .cookie("AuthToken", authToken) + .when().get("users/me") + .then().statusCode(200).contentType(ContentType.JSON) + .body("id", equalTo(1)) + .body("name", equalTo("user")) + .body("surname", equalTo("example")) + .body("username", equalTo("user_example")) + .body("email", equalTo("user@example.com")) + .body("address", equalTo("c/example_address 1")) + .body("roles[0]", equalTo("REGISTERED_USER")); + } + +} diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/ComponentsSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/ComponentsSystemTests.java index 0f77ef7..2923ddc 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/ComponentsSystemTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/ComponentsSystemTests.java @@ -14,6 +14,7 @@ import org.openqa.selenium.WebElement; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.interactions.Actions; import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.WebDriverWait; @@ -21,7 +22,6 @@ public class ComponentsSystemTests { private WebDriver driver; - private WebDriverWait wait; @BeforeEach @@ -33,6 +33,7 @@ public void setupTest() { options.addArguments("--headless"); options.addArguments("--no-sandbox"); options.addArguments("--disable-dev-shm-usage"); + options.addArguments("--window-size=1920,1080"); driver = new ChromeDriver(options); wait = new WebDriverWait(driver, Duration.ofSeconds(10)); @@ -45,6 +46,18 @@ public void teardown() { } } + private void scrollToAndClick(By locator) { + + WebElement element = wait.until(ExpectedConditions.presenceOfElementLocated(locator)); + + new Actions(driver) + .scrollToElement(element) + .perform(); + + wait.until(ExpectedConditions.elementToBeClickable(element)).click(); + + } + @Test public void loadComponentsTest() throws InterruptedException { @@ -56,8 +69,7 @@ public void loadComponentsTest() throws InterruptedException { assertThat(driver.findElements(By.id("name-11"))).isEmpty(); - WebElement loadMoreButton = driver.findElement(By.name("loadMore")); - loadMoreButton.click(); + scrollToAndClick(By.name("loadMore")); wait.until(ExpectedConditions.presenceOfElementLocated(By.id("name-11"))); String loadedComponentName = driver.findElement(By.id("name-11")).getText(); @@ -73,7 +85,6 @@ public void loadErrorTest() { driver.get("http://localhost:5173/components"); wait.until(ExpectedConditions.presenceOfElementLocated(By.name("loadMore"))); - WebElement loadMoreButton = driver.findElement(By.name("loadMore")); // Simulate a network error ((ChromeDriver) driver).executeCdpCommand("Network.enable", Map.of()); @@ -83,7 +94,7 @@ public void loadErrorTest() { "downloadThroughput", 0, "uploadThroughput", 0)); - loadMoreButton.click(); + scrollToAndClick(By.name("loadMore")); wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector("[role='alert']"))); String errorMessage = driver.findElement(By.cssSelector("[role='alert']")).getText(); diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HeaderSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HeaderSystemTests.java new file mode 100644 index 0000000..19de686 --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HeaderSystemTests.java @@ -0,0 +1,145 @@ +package es.codeurjcstudents.pcmod.e2e.ui; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.interactions.Actions; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; + +@Tag("client-system") +public class HeaderSystemTests { + + private WebDriver driver; + private WebDriverWait wait; + + @BeforeEach + public void setupTest() { + ChromeOptions options = new ChromeOptions(); + options.addArguments("--incognito"); + options.addArguments("--disable-notifications"); + options.addArguments("--disable-features=PasswordLeakDetection"); + options.addArguments("--headless"); + options.addArguments("--no-sandbox"); + options.addArguments("--disable-dev-shm-usage"); + options.addArguments("--window-size=1920,1080"); + + driver = new ChromeDriver(options); + wait = new WebDriverWait(driver, Duration.ofSeconds(10)); + } + + @AfterEach + public void teardown() { + if (driver != null) { + driver.quit(); + } + } + + private void scrollToAndClick(By locator) { + WebElement element = wait.until(ExpectedConditions.presenceOfElementLocated(locator)); + + new Actions(driver) + .scrollToElement(element) + .perform(); + + wait.until(ExpectedConditions.elementToBeClickable(element)).click(); + } + + @Test + public void headerRenderTest() { + + driver.get("http://localhost:5173/"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("header"))); + + assertThat(driver.findElement(By.className("logo")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.linkText("Componentes")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.linkText("Iniciar Sesión")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.linkText("Registrarse")).isDisplayed()).isTrue(); + + } + + @Test + public void loginButtonNavigatesToLoginTest() { + + driver.get("http://localhost:5173/"); + + scrollToAndClick(By.linkText("Iniciar Sesión")); + + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/login")); + assertThat(driver.findElement(By.id("login-title")).getText()).isEqualTo("Iniciar sesión:"); + + } + + @Test + public void userDropdownOptionsTest() { + + login("user@example.com", "userpass"); + + scrollToAndClick(By.id("dropdown-user")); + wait.until(ExpectedConditions.visibilityOfElementLocated(By.className("dropdown-menu"))); + + assertThat(wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("profile-option"))).isDisplayed()) + .isTrue(); + assertThat(driver.findElements(By.id("administration-option"))).isEmpty(); + assertThat(wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("logout-option"))).isDisplayed()) + .isTrue(); + + } + + @Test + public void adminDropdownOptionsTest() { + + login("admin@example.com", "adminpass"); + + scrollToAndClick(By.id("dropdown-user")); + wait.until(ExpectedConditions.visibilityOfElementLocated(By.className("dropdown-menu"))); + + assertThat(wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("profile-option"))).isDisplayed()) + .isTrue(); + assertThat(wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("administration-option"))) + .isDisplayed()).isTrue(); + assertThat(wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("logout-option"))).isDisplayed()) + .isTrue(); + + } + + @Test + public void logoutFromUserDropdownTest() { + + login("user@example.com", "userpass"); + + scrollToAndClick(By.id("dropdown-user")); + wait.until(ExpectedConditions.visibilityOfElementLocated(By.className("dropdown-menu"))); + + scrollToAndClick(By.id("logout-option")); + + wait.until(ExpectedConditions.visibilityOfElementLocated(By.linkText("Iniciar Sesión"))); + assertThat(driver.findElements(By.id("dropdown-user"))).isEmpty(); + } + + private void login(String email, String password) { + + driver.get("http://localhost:5173/login"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email"))).sendKeys(email); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("password"))).sendKeys(password); + + scrollToAndClick(By.name("login-button")); + + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("dropdown-user"))); + + } + +} \ No newline at end of file diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HomePageSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HomePageSystemTests.java index 719f35b..f86a595 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HomePageSystemTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HomePageSystemTests.java @@ -13,6 +13,7 @@ import org.openqa.selenium.WebElement; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.interactions.Actions; import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.support.ui.WebDriverWait; @@ -32,6 +33,7 @@ public void setupTest() { options.addArguments("--headless"); options.addArguments("--no-sandbox"); options.addArguments("--disable-dev-shm-usage"); + options.addArguments("--window-size=1920,1080"); driver = new ChromeDriver(options); wait = new WebDriverWait(driver, Duration.ofSeconds(10)); @@ -44,6 +46,16 @@ public void teardown() { } } + private void scrollToAndClick(By locator) { + WebElement element = wait.until(ExpectedConditions.presenceOfElementLocated(locator)); + + new Actions(driver) + .scrollToElement(element) + .perform(); + + wait.until(ExpectedConditions.elementToBeClickable(element)).click(); + } + @Test public void loadHomePage() throws InterruptedException { @@ -60,9 +72,11 @@ public void loadHomePage() throws InterruptedException { wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-9"))); String componentName9 = driver.findElement(By.id("component-9")).getText(); assertThat(componentName9).isEqualTo("AMD Radeon RX 9060 XT DUAL WHITE"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-10"))); String componentName10 = driver.findElement(By.id("component-10")).getText(); assertThat(componentName10).isEqualTo("Seagate BarraCuda 3.5"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("component-11"))); String componentName11 = driver.findElement(By.id("component-11")).getText(); assertThat(componentName11).isEqualTo("Kingston FURY Beast"); @@ -77,11 +91,9 @@ public void navigateToComponentsPage() throws InterruptedException { driver.get("http://localhost:5173/"); - wait.until(ExpectedConditions.presenceOfElementLocated(By.name("componentsButton"))); - WebElement componentsButton = driver.findElement(By.name("componentsButton")); - componentsButton.click(); + scrollToAndClick(By.name("componentsButton")); wait.until(ExpectedConditions.urlToBe("http://localhost:5173/components")); } -} +} \ No newline at end of file diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/UsersSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/UsersSystemTests.java new file mode 100644 index 0000000..30edceb --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/UsersSystemTests.java @@ -0,0 +1,188 @@ +package es.codeurjcstudents.pcmod.e2e.ui; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.interactions.Actions; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; + +@Tag("client-system") +public class UsersSystemTests { + + private WebDriver driver; + + private WebDriverWait wait; + + @BeforeEach + public void setupTest() { + ChromeOptions options = new ChromeOptions(); + options.addArguments("--incognito"); + options.addArguments("--disable-notifications"); + options.addArguments("--disable-features=PasswordLeakDetection"); + options.addArguments("--headless"); + options.addArguments("--no-sandbox"); + options.addArguments("--disable-dev-shm-usage"); + options.addArguments("--window-size=1920,1080"); + + driver = new ChromeDriver(options); + wait = new WebDriverWait(driver, Duration.ofSeconds(10)); + } + + @AfterEach + public void teardown() { + if (driver != null) { + driver.quit(); + } + } + + private void scrollToAndClick(By locator) { + WebElement element = wait.until(ExpectedConditions.presenceOfElementLocated(locator)); + + new Actions(driver) + .scrollToElement(element) + .perform(); + + wait.until(ExpectedConditions.elementToBeClickable(element)).click(); + } + + @Test + public void loginRenderTest() { + + driver.get("http://localhost:5173/login"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("login-title"))); + String loginTitle = driver.findElement(By.id("login-title")).getText(); + assertThat(loginTitle).isEqualTo("Iniciar sesión:"); + + assertThat(driver.findElements(By.id("error-message"))).isEmpty(); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.name("email-title"))); + String emailTitle = driver.findElement(By.name("email-title")).getText(); + assertThat(emailTitle).isEqualTo("Correo electrónico:"); + assertThat(driver.findElements(By.id("email"))).isNotEmpty(); + assertThat(driver.findElement(By.id("invalid-email")).isDisplayed()).isFalse(); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.name("password-title"))); + String passwordTitle = driver.findElement(By.name("password-title")).getText(); + assertThat(passwordTitle).isEqualTo("Contraseña:"); + assertThat(driver.findElements(By.id("password"))).isNotEmpty(); + assertThat(driver.findElement(By.id("invalid-password")).isDisplayed()).isFalse(); + + assertThat(driver.findElements(By.name("login-button"))).isNotEmpty(); + assertThat(driver.findElements(By.id("register-link"))).isNotEmpty(); + + } + + @Test + public void loginVoidCredentialsTest() { + + driver.get("http://localhost:5173/login"); + + scrollToAndClick(By.name("login-button")); + + assertThat(driver.findElement(By.id("invalid-email")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("invalid-email")).getText()) + .isEqualTo("Por favor, ingrese un correo electrónico válido."); + + assertThat(driver.findElement(By.id("invalid-password")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("invalid-password")).getText()) + .isEqualTo("Contraseña incorrecta. Inténtelo de nuevo."); + + } + + @Test + public void loginValidCredentialsTest() { + + driver.get("http://localhost:5173/login"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email"))); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("password"))); + driver.findElement(By.id("email")).sendKeys("user@example.com"); + driver.findElement(By.id("password")).sendKeys("userpass"); + + scrollToAndClick(By.name("login-button")); + + assertThat(driver.findElement(By.id("invalid-email")).isDisplayed()).isFalse(); + assertThat(driver.findElement(By.id("invalid-password")).isDisplayed()).isFalse(); + + wait.until(ExpectedConditions.urlToBe("http://localhost:5173/")); + + } + + @Test + public void loginInvalidEmailFormatTest() { + + driver.get("http://localhost:5173/login"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email"))); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("password"))); + driver.findElement(By.id("email")).sendKeys("user"); + driver.findElement(By.id("password")).sendKeys("userpass"); + + scrollToAndClick(By.name("login-button")); + + assertThat(driver.findElement(By.id("invalid-email")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("invalid-email")).getText()) + .isEqualTo("Por favor, ingrese un correo electrónico válido."); + assertThat(driver.getCurrentUrl()).isEqualTo("http://localhost:5173/login"); + + } + + @Test + public void loginInvalidPassFormatTest() { + + driver.get("http://localhost:5173/login"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email"))); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("password"))); + driver.findElement(By.id("email")).sendKeys("user@example.com"); + driver.findElement(By.id("password")).sendKeys("pass"); + + scrollToAndClick(By.name("login-button")); + + assertThat(driver.findElement(By.id("invalid-password")).isDisplayed()).isTrue(); + assertThat(driver.findElement(By.id("invalid-password")).getText()) + .isEqualTo("Contraseña incorrecta. Inténtelo de nuevo."); + assertThat(driver.getCurrentUrl()).isEqualTo("http://localhost:5173/login"); + + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "wrong email, userWrong@example.com, userpass", + "wrong password, user@example.com, wrongPass", + "wrong credentials, userWrong@example.com, wrongPass" + }) + public void loginWrongCredentialsTest(String testName, String email, String password) { + + driver.get("http://localhost:5173/login"); + + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email"))); + wait.until(ExpectedConditions.presenceOfElementLocated(By.id("password"))); + driver.findElement(By.id("email")).sendKeys(email); + driver.findElement(By.id("password")).sendKeys(password); + + scrollToAndClick(By.name("login-button")); + + wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("error-message"))); + assertThat(driver.findElement(By.id("error-message")).getText()) + .isEqualTo("Error al iniciar sesión. Por favor, inténtenlo de nuevo"); + assertThat(driver.findElement(By.id("invalid-email")).isDisplayed()).isFalse(); + assertThat(driver.findElement(By.id("invalid-password")).isDisplayed()).isFalse(); + + } + +} \ No newline at end of file diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/UsersIntegrationTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/UsersIntegrationTests.java new file mode 100644 index 0000000..e2b341b --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/UsersIntegrationTests.java @@ -0,0 +1,91 @@ +package es.codeurjcstudents.pcmod.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import es.codeurjcstudents.pcmod.model.User; +import es.codeurjcstudents.pcmod.repository.UsersRepository; +import es.codeurjcstudents.pcmod.service.UsersService; + +@Tag("server-integration") +@SpringBootTest +@Testcontainers +public class UsersIntegrationTests { + + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + @Container + private static final MySQLContainer> mysqlContainer = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("TestDB") + .withUsername("TestDBUser") + .withPassword("TestDBPassword"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", + () -> mysqlContainer.getJdbcUrl() + "?useSSL=false&allowPublicKeyRetrieval=true"); + + registry.add("spring.datasource.username", mysqlContainer::getUsername); + registry.add("spring.datasource.password", mysqlContainer::getPassword); + registry.add("spring.datasource.driver-class-name", mysqlContainer::getDriverClassName); + } + + @Autowired + private UsersService usersService; + + @Autowired + private UsersRepository usersRepository; + + @BeforeEach + void setUp() { + usersRepository.deleteAll(); + usersRepository.save(new User("user1", "test", "userTest1", "c/testaddress", "testuser1@example.com", + passwordEncoder.encode("pass"), "REGISTERED_USER")); + usersRepository.save(new User("user2", "test", "userTest2", "c/testaddress", "testuser2@example.com", + passwordEncoder.encode("pass"), "REGISTERED_USER")); + } + + @Test + void createUser() { + User user = new User("user3", "test", "userTest3", "c/testaddress", "testuser3@example.com", + passwordEncoder.encode("pass"), "REGISTERED_USER"); + + User createdUser = usersService.createUser(user); + + assertEquals("user3", createdUser.getName()); + assertEquals("test", createdUser.getSurname()); + assertEquals("userTest3", createdUser.getUsername()); + assertEquals("c/testaddress", createdUser.getAddress()); + assertEquals("testuser3@example.com", createdUser.getEmail()); + + } + + @Test + void getUser() { + User user = usersRepository.save(new User("user3", "test", "userTest3", "c/testaddress", "testuser3@example.com", + passwordEncoder.encode("pass"), "REGISTERED_USER")); + + User loadedUser = usersService.getUser(user.getId()).orElseThrow(); + + assertEquals("user3", loadedUser.getName()); + assertEquals("test", loadedUser.getSurname()); + assertEquals("userTest3", loadedUser.getUsername()); + assertEquals("c/testaddress", loadedUser.getAddress()); + assertEquals("testuser3@example.com", loadedUser.getEmail()); + + } + +} diff --git a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/UsersUnitTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/UsersUnitTests.java new file mode 100644 index 0000000..0f97f7d --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/UsersUnitTests.java @@ -0,0 +1,75 @@ +package es.codeurjcstudents.pcmod.unit; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.NoSuchElementException; + +import es.codeurjcstudents.pcmod.model.User; +import es.codeurjcstudents.pcmod.repository.UsersRepository; +import es.codeurjcstudents.pcmod.service.UsersService; + +@Tag("server-unit") +public class UsersUnitTests { + + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + @Test + public void testCreateUser() { + + UsersRepository usersRepository = mock(UsersRepository.class); + UsersService usersService = new UsersService(usersRepository); + + User user = new User("user", "test", "userTest", "c/testaddress", "testuser@example.com", + passwordEncoder.encode("pass"), "REGISTERED_USER"); + + when(usersRepository.save(user)).thenReturn(user); + + User actualUser = usersService.createUser(user); + + assertEquals(user, actualUser); + verify(usersRepository).save(user); + + } + + @Test + public void testGetUser() { + + UsersRepository usersRepository = mock(UsersRepository.class); + UsersService usersService = new UsersService(usersRepository); + + User user = new User("user", "test", "userTest", "c/testaddress", "testuser@example.com", + passwordEncoder.encode("pass"), "REGISTERED_USER"); + + when(usersRepository.findById(1L)).thenReturn(java.util.Optional.of(user)); + + User actualUser = usersService.getUser(1L).orElseThrow(); + + assertEquals(user, actualUser); + verify(usersRepository).findById(1L); + + } + + @Test + public void testGetUserException() { + + UsersRepository usersRepository = mock(UsersRepository.class); + UsersService usersService = new UsersService(usersRepository); + + when(usersRepository.findById(1L)).thenReturn(java.util.Optional.empty()); + + assertThrows(NoSuchElementException.class, () -> { + usersService.getUser(1L).orElseThrow(); + }); + + } + +} diff --git a/frontend/app/app.css b/frontend/app/app.css index 81ec193..439cb6b 100644 --- a/frontend/app/app.css +++ b/frontend/app/app.css @@ -41,7 +41,8 @@ body { flex-wrap: nowrap; gap: 1.5rem; max-width: 1180px; - margin-left: 0; + width: 100%; + margin: 0 auto; padding: 0.5rem 1rem; } @@ -57,8 +58,8 @@ body { position: relative; padding: 0.65rem 0.1rem; color: #ffffff; - font-size: 0.96rem; - font-weight: 700; + font-size: 1.1rem; + font-weight: 500; text-decoration: none; transition: color 160ms ease; } @@ -87,6 +88,132 @@ body { transform: scaleX(1); } +.header-content>.ms-auto { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.header-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.55rem 0.8rem; + color: #ffffff; + font-size: 1rem; + font-weight: 700; + text-decoration: none; + border: 2px solid rgb(255, 255, 255); + border-radius: 8px; + transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease; +} + +.header-btn:hover, +.header-btn:focus-visible { + color: #ffffff; + background-color: var(--color-brand-dark); + border-color: #ed7e2969; + transform: translateY(-1px); +} + +/* Avatar Dropdown Toggle Button */ + +.avatar-toggle-btn { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + padding: 0; + border: 2px solid rgb(255 255 255 / 70%); + border-radius: 50%; + transition: border-color 160ms ease, transform 160ms ease; + cursor: pointer; +} + +.avatar-toggle-btn::after { + display: none !important; +} + +.avatar-toggle-btn img { + width: 50px; + height: 50px; + object-fit: cover; + border-radius: 50%; +} + +.header .dropdown-menu { + min-width: 190px; + margin-top: 0.65rem; + padding: 0.4rem; + background-color: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: 8px; + box-shadow: 0 10px 24px rgb(84 37 20 / 16%); +} + +.header .dropdown-menu>.dropdown-item:not(.menu-option) { + padding: 0; + background-color: transparent; +} + +.menu-option { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + box-sizing: border-box; + padding: 0.65rem 0.75rem; + color: var(--color-ink); + background-color: transparent; + font-family: var(--font-sans); + font-size: 1rem; + text-decoration: none !important; + border: 0; + border-radius: 4px; +} + +.menu-option:hover, +.menu-option:focus-visible { + color: var(--color-brand-dark); + background-color: #f3dfd1; + text-decoration: none !important; + text-decoration-line: none !important; +} + +.header .dropdown-menu .menu-option::after { + display: none; + content: none; +} + +@media (max-width: 768px) { + .header-content { + gap: 0.75rem; + padding: 0.45rem 0.75rem; + } + + .header .logo img { + width: min(190px, 42vw); + } + + .header-content>ul { + gap: 0.35rem; + } + + .header-content>.ms-auto { + gap: 0.35rem; + } + + .header-btn { + padding: 0.5rem; + font-size: 0; + } + + .header-btn svg { + width: 1.15rem; + height: 1.15rem; + } +} + /* Footer */ .footer { @@ -97,6 +224,15 @@ body { color: #ffffff; } +.footer.sticky-bottom { + position: static; +} + +.social-container>.col-1 { + width: auto; + flex: 0 0 auto; +} + .social-container { display: flex; align-items: center; @@ -135,6 +271,10 @@ body { text-align: center; } +.footer-bottom-content>.mb-0 { + margin-bottom: 1em !important; +} + .sitename { color: #ffffff; font-weight: 1000; @@ -346,4 +486,130 @@ body { 100% { transform: rotate(360deg); } +} + +/* Login */ + +.main:has(.login-form) .container { + max-width: 520px; +} + +.login-form { + padding: clamp(1.5rem, 5vw, 3rem); + background-color: var(--color-surface); + border: 1px solid var(--color-line); + border-top: 5px solid var(--color-principal); + border-radius: 8px; + box-shadow: 0 14px 30px rgb(84 37 20 / 12%); +} + +.login-form>a:first-child { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--color-brand-dark); + font-size: 0.9rem; + font-weight: 700; + text-decoration: none; +} + +.login-form>a:first-child:hover, +.login-form>a:first-child:focus-visible { + color: var(--color-principal); +} + +.login-form h3 { + margin: 2rem 0 1.75rem; + color: var(--color-ink); + font-size: clamp(1.45rem, 4vw, 1.85rem); + font-weight: 700; +} + +.custom-login-form .form { + display: grid; + gap: 0.5rem; + margin-bottom: 1.75rem; +} + +.form-field { + display: grid; + gap: 0.25rem; +} + +.custom-login-form .form-control { + min-height: 46px; + padding: 0 0 0 0.5rem; + color: var(--color-ink); + background-color: #fffdfb; + border: 1px solid var(--color-line); + border-radius: 8px; + font-size: 1rem; + font-family: var(--font-sans); +} + +.custom-login-form .form-control:focus { + color: var(--color-ink); + background-color: #fffdfb; + border-color: var(--color-line); + box-shadow: none; + outline: none; +} + +.custom-login-form .invalid-feedback { + display: none; + margin-bottom: 0.75rem; + color: var(--color-brand-dark); + font-size: 0.85rem; +} + +.custom-login-form.was-validated .form-control:invalid~.invalid-feedback { + display: block; +} + +.custom-login-form>.pcmod-button { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: fit-content; + margin-right: auto; + margin-left: auto; +} + +.error-message-text { + margin-bottom: 1.5rem; + padding: 1rem 1rem 1rem 0.75rem; + color: var(--color-brand-dark); + background-color: #f9dcca; + border: 1px solid #ff0404; + border-left: 4px solid var(--color-brand); + border-radius: 8px; + font-size: 0.85rem; +} + +.custom-login-form>p { + margin-top: 1.25rem; + margin-bottom: 0.45rem; + color: var(--color-muted); + font-size: 0.85rem; + text-align: center; +} + +.link-text { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + color: var(--color-principal); + font-size: 0.85rem; + font-weight: 700; + text-align: center; + text-decoration: none; +} + +.link-text:hover, +.link-text:focus-visible { + color: var(--color-principal); + text-decoration: underline; + text-underline-offset: 3px; } \ No newline at end of file diff --git a/frontend/app/components/header.tsx b/frontend/app/components/header.tsx index 3b3c103..6b4dbb7 100644 --- a/frontend/app/components/header.tsx +++ b/frontend/app/components/header.tsx @@ -1,20 +1,88 @@ -import { useEffect, useState } from "react"; -import { Link } from "react-router"; -import { Image } from "react-bootstrap"; +import { useEffect } from "react"; +import { Link, useNavigate } from "react-router"; +import { Dropdown, Image } from "react-bootstrap"; +import { useUserStore } from "~/stores/user-store"; +import { BoxArrowInRight, Person, PersonAdd, BoxArrowRight, ShieldLock } from "react-bootstrap-icons"; export default function Header() { + let { user, loadLoggedUser, logoutUser } = useUserStore(); + const navigate = useNavigate(); - return <> + useEffect(() => { + loadLoggedUser(); + }, [loadLoggedUser]); + + const handleLogout = () => { + logoutUser(); + navigate("/"); + }; + + return ( - - + Componentes + + {user ? ( + + + + + + + + Mi perfil + + + {user.roles?.includes("ADMIN") && ( + <> + + + Administración + + > + )} + + + + + Cerrar sesión + + + + ) : ( + <> + + + Iniciar Sesión + + + + Registrarse + + > + )} + - >; + ); } \ No newline at end of file diff --git a/frontend/app/dtos/UserDTO.ts b/frontend/app/dtos/UserDTO.ts new file mode 100644 index 0000000..0f8d045 --- /dev/null +++ b/frontend/app/dtos/UserDTO.ts @@ -0,0 +1,9 @@ +export default interface UserDTO { + id: number; + name: string; + surname: string; + username: string; + address: string; + email: string; + roles: Array +} \ No newline at end of file diff --git a/frontend/app/root.tsx b/frontend/app/root.tsx index 79b2824..28725f7 100644 --- a/frontend/app/root.tsx +++ b/frontend/app/root.tsx @@ -6,6 +6,7 @@ import { ScrollRestoration, } from "react-router"; +import "bootstrap/dist/css/bootstrap.min.css"; import "./app.css"; export function Layout({ children }: { children: React.ReactNode }) { diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index 18ee9cd..aa61fd1 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -3,6 +3,7 @@ import { type RouteConfig, layout, route } from "@react-router/dev/routes"; export default [ layout("routes/home.tsx", [ route("/", "routes/index.tsx"), + route("/login", "routes/login.tsx"), route("/components", "routes/components.tsx"), ]), ] satisfies RouteConfig; diff --git a/frontend/app/routes/login.tsx b/frontend/app/routes/login.tsx new file mode 100644 index 0000000..04dc0d3 --- /dev/null +++ b/frontend/app/routes/login.tsx @@ -0,0 +1,118 @@ +import { useActionState, useRef, useState, startTransition } from "react"; +import type { SubmitEvent } from "react"; +import { Button, FormControl, FormLabel } from "react-bootstrap"; +import { ArrowLeft, BoxArrowInRight, ExclamationCircleFill, PersonPlus } from "react-bootstrap-icons"; +import { Link, useNavigate } from "react-router"; +import { useUserStore } from "~/stores/user-store"; + +export default function Login() { + const userStore = useUserStore() + const navigate = useNavigate() + const [wasValidated, setWasValidated] = useState(false) + const formRef = useRef(null); + const [errorMessage, setErrorMessage] = useState("") + const [state, formAction, isPending] = useActionState( + loginAction, + { email: "", password: "" } + ) + + async function loginAction(prevState: {}, formData: FormData) { + + const email = (formData.get("email") as string) ?? ""; + const password = (formData.get("password") as string) ?? ""; + + const form = formRef.current; + setWasValidated(true) + + if (form?.checkValidity()) { + + try { + + await userStore.loginUser(email, password) + const error = useUserStore.getState().loginError; + + if (error) { + setErrorMessage(error) + } else { + navigate(`/`); + } + + } catch (error) { + + console.log(error); + setErrorMessage("Hubo un error al iniciar sesión. Por favor, inténtalo de nuevo."); + + } + + } + + return { email, password } + } + + function handleSubmit(event: SubmitEvent) { + + event.preventDefault(); + const form = event.currentTarget; + + if (!form.checkValidity()) { + + event.stopPropagation(); + setWasValidated(true); + + } else { + + setWasValidated(true); + startTransition(() => { + formAction(new FormData(form)); + }); + + } + + } + + return ( + + + + + + Volver + + Iniciar sesión: + {errorMessage && ( + + Error: + {errorMessage} + + )} + + + + Correo electrónico: + + + Por favor, ingrese un correo electrónico válido. + + + + Contraseña: + + + Contraseña incorrecta. Inténtelo de nuevo. + + + + + Iniciar sesión + + + ¿No tiene cuenta en PCMod? + + Haga click aquí para crear una. + + + + + + ); +} \ No newline at end of file diff --git a/frontend/app/services/login-service.ts b/frontend/app/services/login-service.ts new file mode 100644 index 0000000..d1330a3 --- /dev/null +++ b/frontend/app/services/login-service.ts @@ -0,0 +1,62 @@ +import type UserDTO from "~/dtos/UserDTO"; + +const API_USERS_URL = "/api/v1/users"; +const API_AUTH_URL = "/api/v1/auth"; + +function getBaseUrl(): string { + const baseUrl = typeof window !== "undefined" && window.location.origin + ? window.location.origin + : "https://localhost:443"; + return baseUrl; +} + +export class HttpError extends Error { + + status: number; + + constructor(status: number, message?: string) { + super(message ?? `HTTP ${status}`); + this.status = status; + } + +} + +export async function reqIsLogged(): Promise { + + const url = new URL(`${API_USERS_URL}/me`, getBaseUrl()); + const res = await fetch(url.toString()); + + if (!res.ok) { + throw new HttpError(res.status); + } + + return await res.json(); +} + +export async function logIn(email: string, pass: string): Promise { + + const url = new URL(`${API_AUTH_URL}/login`, getBaseUrl()); + const res = await fetch(url.toString(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: email, password: pass }), + }); + + if (!res.ok) { + throw new Error("There was an error while logging in."); + } + +} + +export async function logOut(): Promise { + + const url = new URL(`${API_AUTH_URL}/logout`, getBaseUrl()); + const res = await fetch(url.toString(), { + method: "POST", + }); + + if (!res.ok) { + throw new Error("There was an error while logging out."); + } + +} \ No newline at end of file diff --git a/frontend/app/stores/user-store.tsx b/frontend/app/stores/user-store.tsx new file mode 100644 index 0000000..7a136cf --- /dev/null +++ b/frontend/app/stores/user-store.tsx @@ -0,0 +1,77 @@ +import { create } from "zustand"; + +import type UserDTO from "~/dtos/UserDTO"; +import { HttpError, logIn, logOut, reqIsLogged } from "~/services/login-service"; + +interface UserState { + user: UserDTO | null; + loginError: string | null; + loadLoggedUser: () => Promise; + loginUser: (username: string, password: string) => Promise; + logoutUser: () => Promise; +} + +export const useUserStore = create((set, get) => ({ + + user: null, + loginError: null, + + loadLoggedUser: async () => { + + set({ user: null, loginError: null }); + + try { + + const user = await reqIsLogged(); + set({ user }); + + } catch (error) { + + if (error instanceof HttpError && error.status === 401) { + set({ user: null, loginError: null }); + return; + } + + console.log(error); + set({ loginError: "Error al cargar el usuario" }); + } + + }, + + loginUser: async (username: string, password: string) => { + + set({ user: null, loginError: null }); + + try { + + await logIn(username, password); + await get().loadLoggedUser(); + + } catch (error) { + + console.log(error); + const message = "Error al iniciar sesión. Por favor, inténtenlo de nuevo"; + set({ loginError: message }); + + } + + }, + + logoutUser: async () => { + + set({ user: null, loginError: null }); + + try { + + await logOut(); + + } catch (error) { + + console.log(error); + set({ loginError: "Error al cerrar sesión. Por favor, inténtenlo de nuevo" }); + + } + + }, + +})); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 57ee77b..a886ba8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,7 +14,8 @@ "react-bootstrap": "^2.10.10", "react-bootstrap-icons": "^1.11.6", "react-dom": "^19.2.7", - "react-router": "^8.3.0" + "react-router": "^8.3.0", + "zustand": "^5.0.15" }, "devDependencies": { "@react-router/dev": "^8.3.0", @@ -26,7 +27,9 @@ "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.10", "@vitest/ui": "^4.1.10", + "fetch-cookie": "^3.2.0", "jsdom": "^29.1.1", + "tough-cookie": "^6.0.2", "typescript": "^5.9.3", "vite": "^8.0.3", "vite-tsconfig-paths": "^5.1.4", @@ -2317,6 +2320,17 @@ } } }, + "node_modules/fetch-cookie": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-3.2.0.tgz", + "integrity": "sha512-n61pQIxP25C6DRhcJxn7BDzgHP/+S56Urowb5WFxtcRMpU6drqXD90xjyAsVQYsNSNNVbaCcYY1DuHsdkZLuiA==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^6.0.0" + } + }, "node_modules/fflate": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", @@ -3895,6 +3909,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -4658,6 +4679,35 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index c2db133..151c8e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,8 @@ "react-bootstrap": "^2.10.10", "react-bootstrap-icons": "^1.11.6", "react-dom": "^19.2.7", - "react-router": "^8.3.0" + "react-router": "^8.3.0", + "zustand": "^5.0.15" }, "devDependencies": { "@react-router/dev": "^8.3.0", @@ -34,10 +35,12 @@ "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.10", "@vitest/ui": "^4.1.10", + "fetch-cookie": "^3.2.0", "jsdom": "^29.1.1", + "tough-cookie": "^6.0.2", "typescript": "^5.9.3", "vite": "^8.0.3", "vite-tsconfig-paths": "^5.1.4", "vitest": "^4.1.10" } -} \ No newline at end of file +} diff --git a/frontend/public/empty-profile-image.jpg b/frontend/public/empty-profile-image.jpg new file mode 100644 index 0000000..31df1b7 Binary files /dev/null and b/frontend/public/empty-profile-image.jpg differ diff --git a/frontend/tests/integration/Header.integration.test.tsx b/frontend/tests/integration/Header.integration.test.tsx new file mode 100644 index 0000000..95385ef --- /dev/null +++ b/frontend/tests/integration/Header.integration.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import fetchCookie from "fetch-cookie"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom"; +import Header from "~/components/header"; +import Login from "~/routes/login"; +import { useUserStore } from "~/stores/user-store"; +import { CookieJar } from "tough-cookie"; + +describe("HeaderIntegration", () => { + + beforeEach(() => { + vi.stubGlobal("fetch", fetchCookie(globalThis.fetch, new CookieJar())); + useUserStore.setState({ user: null, loginError: null }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows the profile and logout options after logging in as a user", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/", Component: Header }, + { path: "/login", Component: Login }, + ]); + + render() + + await user.type(screen.getByLabelText("Correo electrónico:"), "user@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "userpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await user.click(await screen.findByRole("button", { name: "User" })); + + expect(screen.getByText("Mi perfil")).toBeInTheDocument(); + expect(screen.queryByText("Administración")).not.toBeInTheDocument(); + expect(screen.getByText("Cerrar sesión")).toBeInTheDocument(); + + }); + + it("shows the administration option after logging in as an admin", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/", Component: Header }, + { path: "/login", Component: Login }, + ]); + + render() + + await user.type(screen.getByLabelText("Correo electrónico:"), "admin@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "adminpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await user.click(await screen.findByRole("button", { name: "User" })); + + expect(screen.getByText("Mi perfil")).toBeInTheDocument(); + expect(screen.getByText("Administración")).toBeInTheDocument(); + expect(screen.getByText("Cerrar sesión")).toBeInTheDocument(); + + }); + + it("closes the user session from the dropdown", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/", Component: Header }, + { path: "/login", Component: Login }, + ]); + + render() + + await user.type(screen.getByLabelText("Correo electrónico:"), "user@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "userpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await user.click(await screen.findByRole("button", { name: "User" })); + await user.click(screen.getByText("Cerrar sesión")); + + await waitFor(() => { + expect(screen.getByRole("link", { name: /Iniciar Sesión/i })).toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: "User" })).not.toBeInTheDocument(); + + }); + +}); diff --git a/frontend/tests/integration/Login.integration.test.tsx b/frontend/tests/integration/Login.integration.test.tsx new file mode 100644 index 0000000..5eac9d6 --- /dev/null +++ b/frontend/tests/integration/Login.integration.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import fetchCookie from "fetch-cookie"; +import userEvent from "@testing-library/user-event"; +import { createRoutesStub } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom"; +import { CookieJar } from "tough-cookie"; +import Login from "~/routes/login"; + +describe("LoginIntegration", () => { + + beforeEach(() => { + vi.stubGlobal("fetch", fetchCookie(globalThis.fetch, new CookieJar())); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the login form and validates empty credentials", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + ]); + + render(); + + expect(screen.getByRole("heading", { name: "Iniciar sesión:" })).toBeInTheDocument(); + expect(screen.getByLabelText("Correo electrónico:")).toBeInTheDocument(); + expect(screen.getByLabelText("Contraseña:")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + expect(screen.getByText("Por favor, ingrese un correo electrónico válido.")).toBeInTheDocument(); + expect(screen.getByText("Contraseña incorrecta. Inténtelo de nuevo.")).toBeInTheDocument(); + }); + + it("logs in with valid credentials and navigates to the home page", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => HomePage }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "user@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "userpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "HomePage" })).toBeInTheDocument(); + }); + }); + + it("rejects an invalid email format", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "user"); + await user.type(screen.getByLabelText("Contraseña:"), "userpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + expect(screen.getByText("Por favor, ingrese un correo electrónico válido.")).toBeInTheDocument(); + }); + + it("rejects an invalid password format", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "user@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "pass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + expect(screen.getByText("Contraseña incorrecta. Inténtelo de nuevo.")).toBeInTheDocument(); + }); + + it("shows the backend error when credentials are rejected", async () => { + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "wrongUser@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "wrongPass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await waitFor(() => { + expect(screen.getByText("Error al iniciar sesión. Por favor, inténtenlo de nuevo")).toBeInTheDocument(); + }); + }); + +}); \ No newline at end of file diff --git a/frontend/tests/unit/Header.test.tsx b/frontend/tests/unit/Header.test.tsx new file mode 100644 index 0000000..10b0109 --- /dev/null +++ b/frontend/tests/unit/Header.test.tsx @@ -0,0 +1,141 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom"; +import Login from "~/routes/login"; +import Header from "~/components/header"; +import { createRoutesStub } from "react-router"; +import userEvent from "@testing-library/user-event"; +import { logIn, logOut, reqIsLogged } from "~/services/login-service"; +import { useUserStore } from "~/stores/user-store"; + +vi.mock("~/services/login-service", () => ({ + logIn: vi.fn(), + logOut: vi.fn(), + reqIsLogged: vi.fn(), +})); + +const userAccount = { + id: 1, + name: "User", + surname: "Example", + username: "user@example.com", + address: "User address", + email: "user@example.com", + roles: ["REGISTERED_USER"], +}; + +const adminAccount = { + id: 2, + name: "Admin", + surname: "Example", + username: "admin@example.com", + address: "Admin address", + email: "admin@example.com", + roles: ["REGISTERED_USER", "ADMIN"], +}; + +describe("Header", () => { + + beforeEach(() => { + vi.clearAllMocks(); + useUserStore.setState({ user: null, loginError: null }); + vi.mocked(logIn).mockResolvedValue(undefined); + vi.mocked(logOut).mockResolvedValue(undefined); + vi.mocked(reqIsLogged).mockResolvedValue(null as never); + }); + + it("renders the initial header", () => { + + const RouterStub = createRoutesStub([ + { path: "/", Component: Header }, + { path: "/login", Component: Login }, + ]); + + render() + + expect(screen.getByAltText("Logo")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Componentes" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Iniciar Sesión/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Registrarse/i })).toBeInTheDocument(); + + }); + + it("navigates to /login when clicking the login button", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/", Component: Header }, + { path: "/login", Component: Login }, + ]); + + render() + + await user.click(screen.getByRole("link", { name: /Iniciar Sesión/i })); + + expect(screen.getByRole("heading", { name: "Iniciar sesión:" })).toBeInTheDocument(); + + }); + + it("shows the profile and logout options for a logged-in user", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/", Component: Header }, + { path: "/login", Component: Login }, + ]); + + vi.mocked(reqIsLogged).mockResolvedValue(userAccount); + + render(); + + await user.click(await screen.findByRole("button", { name: "User" })); + + expect(screen.getByText("Mi perfil")).toBeInTheDocument(); + expect(screen.queryByText("Administración")).not.toBeInTheDocument(); + expect(screen.getByText("Cerrar sesión")).toBeInTheDocument(); + + }); + + it("shows the administration option for an admin user", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/", Component: Header }, + { path: "/login", Component: Login }, + ]); + + vi.mocked(reqIsLogged).mockResolvedValue(adminAccount); + + render(); + + await user.click(await screen.findByRole("button", { name: "User" })); + + expect(screen.getByText("Mi perfil")).toBeInTheDocument(); + expect(screen.getByText("Administración")).toBeInTheDocument(); + expect(screen.getByText("Cerrar sesión")).toBeInTheDocument(); + + }); + + it("logs out the user from the dropdown", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/", Component: Header }, + { path: "/login", Component: Login }, + ]); + + vi.mocked(reqIsLogged).mockResolvedValue(userAccount); + + render(); + + await user.click(await screen.findByRole("button", { name: "User" })); + await user.click(screen.getByText("Cerrar sesión")); + + await waitFor(() => { + expect(logOut).toHaveBeenCalledTimes(1); + expect(screen.getByRole("link", { name: /Iniciar Sesión/i })).toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: "User" })).not.toBeInTheDocument(); + }); + +}); \ No newline at end of file diff --git a/frontend/tests/unit/LoginPage.test.tsx b/frontend/tests/unit/LoginPage.test.tsx new file mode 100644 index 0000000..74867fc --- /dev/null +++ b/frontend/tests/unit/LoginPage.test.tsx @@ -0,0 +1,136 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import "@testing-library/jest-dom"; +import { logIn, reqIsLogged } from "~/services/login-service"; +import Login from "~/routes/login"; +import { createRoutesStub } from "react-router"; +import userEvent from "@testing-library/user-event"; + +vi.mock("~/services/login-service", () => ({ + logIn: vi.fn(), + reqIsLogged: vi.fn(), +})); + +describe("LoginPage", () => { + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the login form", async () => { + + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + ]); + + render(); + + expect(screen.getByRole("heading", { name: "Iniciar sesión:" })).toBeInTheDocument(); + expect(screen.getByLabelText("Correo electrónico:")).toBeInTheDocument(); + expect(screen.getByLabelText("Contraseña:")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Iniciar sesión/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Haga click aquí para crear una." })).toBeInTheDocument(); + + }); + + it("validates empty credentials", async () => { + const user = userEvent.setup(); + + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + ]); + + render(); + + const loginButton = screen.getByRole("button", { name: /Iniciar sesión/i }); + + await user.click(loginButton); + + expect(screen.getByText("Por favor, ingrese un correo electrónico válido.")).toBeInTheDocument(); + expect(screen.getByText("Contraseña incorrecta. Inténtelo de nuevo.")).toBeInTheDocument(); + expect(logIn).not.toHaveBeenCalled(); + + }); + + it("log in a user with correct credentials", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + { path: "/", Component: () => HomePage }, + ]); + + vi.mocked(logIn).mockResolvedValue(undefined); + vi.mocked(reqIsLogged).mockResolvedValue({} as never); + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "user@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "userpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "HomePage" })).toBeInTheDocument(); + }); + expect(logIn).toHaveBeenCalledWith("user@example.com", "userpass"); + + }); + + it("rejects login with invalid email format", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "user"); + await user.type(screen.getByLabelText("Contraseña:"), "userpass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + expect(screen.getByText("Por favor, ingrese un correo electrónico válido.")).toBeInTheDocument(); + expect(logIn).not.toHaveBeenCalled(); + + }); + + it("rejects login with invalid password format", async () => { + + const user = userEvent.setup(); + const checkValiditySpy = vi.spyOn(HTMLFormElement.prototype, "checkValidity").mockReturnValue(false); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + ]); + + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "user@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "pass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + expect(screen.getByText("Contraseña incorrecta. Inténtelo de nuevo.")).toBeInTheDocument(); + expect(logIn).not.toHaveBeenCalled(); + checkValiditySpy.mockRestore(); + + }); + + it("displays error message if invalid credentials are provided", async () => { + + const user = userEvent.setup(); + const RouterStub = createRoutesStub([ + { path: "/login", Component: Login }, + ]); + + vi.mocked(logIn).mockRejectedValue(new Error("Invalid credentials")); + render(); + + await user.type(screen.getByLabelText("Correo electrónico:"), "wrongUser@example.com"); + await user.type(screen.getByLabelText("Contraseña:"), "wrongPass"); + await user.click(screen.getByRole("button", { name: /Iniciar sesión/i })); + + await waitFor(() => { + expect(screen.getByText("Error al iniciar sesión. Por favor, inténtenlo de nuevo")).toBeInTheDocument(); + }); + expect(logIn).toHaveBeenCalledWith("wrongUser@example.com", "wrongPass"); + + }); +}); \ No newline at end of file diff --git a/sonar-basic.properties b/sonar-basic.properties index 7ecadde..6632fd9 100644 --- a/sonar-basic.properties +++ b/sonar-basic.properties @@ -8,5 +8,6 @@ sonar.java.binaries=backend/pcmod/target/classes sonar.coverage.jacoco.xmlReportPaths=backend/pcmod/target/site/jacoco/jacoco.xml sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info -sonar.exclusions=backend/pcmod/target/**,backend/**/PcmodApplication.java,backend/**/SpaRoutingConfig.java,backend/**/security/**,backend/**/controller/**,backend/**/service/DatabaseInitializer.java,backend/**/repository/**,backend/**/dto/**,frontend/node_modules/**,frontend/dist/**,frontend/build/**,frontend/app/components/header.tsx,frontend/app/components/footer.tsx,frontend/app/routes/home.tsx,frontend/app/app.css,frontend/app/root.tsx,frontend/app/routes.ts,frontend/app/services/** +sonar.exclusions=backend/pcmod/target/**,backend/**/PcmodApplication.java,backend/**/SpaRoutingConfig.java,backend/**/security/**,backend/**/controller/**,backend/**/service/DatabaseInitializer.java,backend/**/repository/**,backend/**/dto/**,frontend/node_modules/**,frontend/dist/**,frontend/build/**,frontend/app/components/header.tsx,frontend/app/components/footer.tsx,frontend/app/routes/home.tsx,frontend/app/app.css,frontend/app/root.tsx,frontend/app/routes.ts,frontend/app/services/**,frontend/app/stores/** +sonar.coverage.exclusions=backend/**/model/** sonar.qualitygate.wait=true \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties index fc0864c..8dced39 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -8,5 +8,6 @@ sonar.java.binaries=backend/pcmod/target/classes sonar.coverage.jacoco.xmlReportPaths=backend/pcmod/target/site/jacoco/jacoco.xml sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info -sonar.exclusions=backend/pcmod/target/**,backend/**/PcmodApplication.java,backend/**/SpaRoutingConfig.java,frontend/node_modules/**,frontend/dist/**,frontend/build/**,frontend/app/components/header.tsx,frontend/app/components/footer.tsx,frontend/app/routes/home.tsx,frontend/app/app.css,frontend/app/root.tsx,frontend/app/routes.ts +sonar.exclusions=backend/pcmod/target/**,backend/**/PcmodApplication.java,backend/**/SpaRoutingConfig.java,backend/**/security/**,frontend/node_modules/**,frontend/dist/**,frontend/build/**,frontend/app/components/header.tsx,frontend/app/components/footer.tsx,frontend/app/routes/home.tsx,frontend/app/app.css,frontend/app/root.tsx,frontend/app/routes.ts,frontend/app/stores/** +sonar.coverage.exclusions=backend/**/model/** sonar.qualitygate.wait=true \ No newline at end of file
{errorMessage}
¿No tiene cuenta en PCMod?