From cf11a22d20df96361b1ce8a5e302660ba1020acf Mon Sep 17 00:00:00 2001 From: jaimeeg5 Date: Mon, 7 Sep 2026 15:39:08 +0200 Subject: [PATCH 1/4] feat: add login backend and tests --- backend/pcmod/pom.xml | 21 +++ .../controller/auth/LoginRestController.java | 43 ++++++ .../codeurjcstudents/pcmod/dto/UserDTO.java | 13 ++ .../pcmod/dto/UserMapper.java | 12 ++ .../es/codeurjcstudents/pcmod/model/User.java | 125 ++++++++++++++++++ .../pcmod/repository/UsersRepository.java | 13 ++ .../security/CSRFHandlerConfiguration.java | 36 +++++ .../RepositoryUserDetailsService.java | 38 ++++++ .../pcmod/security/SecurityConfig.java | 43 ++++++ .../pcmod/security/jwt/AuthResponse.java | 56 ++++++++ .../pcmod/security/jwt/JwtRequestFilter.java | 56 ++++++++ .../pcmod/security/jwt/JwtTokenProvider.java | 83 ++++++++++++ .../pcmod/security/jwt/LoginRequest.java | 36 +++++ .../pcmod/security/jwt/TokenType.java | 20 +++ .../security/jwt/UnauthorizedHandlerJwt.java | 27 ++++ .../pcmod/security/jwt/UserLoginService.java | 99 ++++++++++++++ .../pcmod/service/DatabaseInitializer.java | 17 +++ .../pcmod/service/UsersService.java | 23 ++++ .../src/main/resources/application.properties | 3 + .../pcmod/e2e/api/UsersSystemTests.java | 100 ++++++++++++++ .../integration/UsersIntegrationTests.java | 76 +++++++++++ .../pcmod/unit/UsersUnitTests.java | 45 +++++++ 22 files changed, 985 insertions(+) create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/auth/LoginRestController.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/UserDTO.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/dto/UserMapper.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/model/User.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/repository/UsersRepository.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/CSRFHandlerConfiguration.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/RepositoryUserDetailsService.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/AuthResponse.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/JwtRequestFilter.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/JwtTokenProvider.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/LoginRequest.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/TokenType.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/UnauthorizedHandlerJwt.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/jwt/UserLoginService.java create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/UsersService.java create mode 100644 backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/UsersSystemTests.java create mode 100644 backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/UsersIntegrationTests.java create mode 100644 backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/UsersUnitTests.java 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/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..3428ef9 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/RepositoryUserDetailsService.java @@ -0,0 +1,38 @@ +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) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + List roles = new ArrayList<>(); + for (String role : user.getRoles()) { + roles.add(new SimpleGrantedAuthority("ROLE_" + role)); + } + + return new org.springframework.security.core.userdetails.User(user.getEmail(), 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..2ca396b 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,66 @@ 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 AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailService); + authProvider.setPasswordEncoder(passwordEncoder()); + + return authProvider; + } + @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + 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..516b9a7 --- /dev/null +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/UsersService.java @@ -0,0 +1,23 @@ +package es.codeurjcstudents.pcmod.service; + +import java.util.List; + +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); + } + +} 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..bb7f08f --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/api/UsersSystemTests.java @@ -0,0 +1,100 @@ +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/auth"; + RestAssured.useRelaxedHTTPSValidation(); + } + + @Test + public void login() { + given() + .header("Content-Type", "application/json") + .body(""" + { + "username": "user@example.com", + "password": "userpass" + } + """) + .when().post("/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("/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("/login") + .then().statusCode(200) + .extract().cookie("RefreshToken"); + + given() + .cookie("RefreshToken", refreshToken) + .when().post("/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("/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()); + } + +} 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..6766872 --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/UsersIntegrationTests.java @@ -0,0 +1,76 @@ +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()); + + } + +} 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..bc4ce87 --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/UsersUnitTests.java @@ -0,0 +1,45 @@ +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.List; +import java.util.NoSuchElementException; +import java.util.Optional; + +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); + + } + +} From a8e9c61657c3de738fa19aa10edd6e43955f54ad Mon Sep 17 00:00:00 2001 From: jaimeeg5 Date: Tue, 8 Sep 2026 12:39:22 +0200 Subject: [PATCH 2/4] chore: update SonarCloud configuration --- sonar-basic.properties | 1 + sonar-project.properties | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/sonar-basic.properties b/sonar-basic.properties index 7ecadde..2893f43 100644 --- a/sonar-basic.properties +++ b/sonar-basic.properties @@ -9,4 +9,5 @@ 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.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..053b9f5 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 +sonar.coverage.exclusions=backend/**/model/** sonar.qualitygate.wait=true \ No newline at end of file From cf2b4e185c4ed64a961dbc14b3a2c017d5afefda Mon Sep 17 00:00:00 2001 From: jaimeeg5 Date: Fri, 11 Sep 2026 15:20:10 +0200 Subject: [PATCH 3/4] feat: add login frontend and tests fix: login backend issues --- .../pcmod/controller/UsersRestController.java | 43 +++++ .../RepositoryUserDetailsService.java | 5 +- .../pcmod/security/SecurityConfig.java | 12 +- .../pcmod/service/UsersService.java | 6 +- .../pcmod/e2e/api/UsersSystemTests.java | 45 ++++- .../pcmod/e2e/ui/UsersSystemTests.java | 175 ++++++++++++++++++ .../integration/UsersIntegrationTests.java | 15 ++ .../pcmod/unit/UsersUnitTests.java | 34 +++- frontend/app/app.css | 118 ++++++++++++ frontend/app/dtos/UserDTO.ts | 8 + frontend/app/routes.ts | 1 + frontend/app/routes/login.tsx | 118 ++++++++++++ frontend/app/services/login-service.ts | 62 +++++++ frontend/app/stores/user-store.tsx | 77 ++++++++ frontend/package-lock.json | 52 +++++- frontend/package.json | 7 +- .../integration/Login.integration.test.tsx | 103 +++++++++++ frontend/tests/unit/LoginPage.test.tsx | 136 ++++++++++++++ sonar-basic.properties | 2 +- sonar-project.properties | 2 +- 20 files changed, 1000 insertions(+), 21 deletions(-) create mode 100644 backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/controller/UsersRestController.java create mode 100644 backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/UsersSystemTests.java create mode 100644 frontend/app/dtos/UserDTO.ts create mode 100644 frontend/app/routes/login.tsx create mode 100644 frontend/app/services/login-service.ts create mode 100644 frontend/app/stores/user-store.tsx create mode 100644 frontend/tests/integration/Login.integration.test.tsx create mode 100644 frontend/tests/unit/LoginPage.test.tsx 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/security/RepositoryUserDetailsService.java b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/RepositoryUserDetailsService.java index 3428ef9..e4a1689 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/RepositoryUserDetailsService.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/security/RepositoryUserDetailsService.java @@ -24,14 +24,15 @@ public class RepositoryUserDetailsService implements UserDetailsService { public UserDetails loadUserByUsername(String identifier) throws UsernameNotFoundException { User user = userRepository.findByEmail(identifier) - .orElseThrow(() -> new UsernameNotFoundException("User not found")); + .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.getEmail(), user.getEncodedPassword(), + 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 2ca396b..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 @@ -34,11 +34,6 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { - return authConfig.getAuthenticationManager(); - } - @Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailService); @@ -47,9 +42,16 @@ public DaoAuthenticationProvider authenticationProvider() { 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)); 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 index 516b9a7..80a0ebd 100644 --- a/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/UsersService.java +++ b/backend/pcmod/src/main/java/es/codeurjcstudents/pcmod/service/UsersService.java @@ -1,6 +1,6 @@ package es.codeurjcstudents.pcmod.service; -import java.util.List; +import java.util.Optional; import org.springframework.stereotype.Service; @@ -20,4 +20,8 @@ public User createUser(User user) { return userRepository.save(user); } + public Optional getUser(long id) { + return userRepository.findById(id); + } + } 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 index bb7f08f..5ac4c4e 100644 --- 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 @@ -22,7 +22,7 @@ public class UsersSystemTests { public void setup() { RestAssured.port = port; RestAssured.baseURI = "https://localhost"; - RestAssured.basePath = "/api/v1/auth"; + RestAssured.basePath = "/api/v1/"; RestAssured.useRelaxedHTTPSValidation(); } @@ -36,7 +36,7 @@ public void login() { "password": "userpass" } """) - .when().post("/login") + .when().post("auth/login") .then().statusCode(200).contentType(ContentType.JSON) .body("error", nullValue()) .body("message", equalTo("Auth successful. Tokens are created in cookie.")) @@ -55,7 +55,7 @@ public void loginError() { "password": "pass" } """) - .when().post("/login") + .when().post("auth/login") .then().statusCode(401).contentType(ContentType.JSON) .body("error", equalTo("Unauthorized")) .body("status", equalTo(401)) @@ -72,13 +72,13 @@ public void refresh() { "password": "userpass" } """) - .when().post("/login") + .when().post("auth/login") .then().statusCode(200) .extract().cookie("RefreshToken"); given() .cookie("RefreshToken", refreshToken) - .when().post("/refresh") + .when().post("auth/refresh") .then().statusCode(200).contentType(ContentType.JSON) .body("error", nullValue()) .body("message", equalTo("Auth successful. Tokens are created in cookie.")) @@ -88,7 +88,7 @@ public void refresh() { @Test public void logout() { given().header("Content-Type", "application/json") - .when().post("/logout") + .when().post("auth/logout") .then().statusCode(200).contentType(ContentType.JSON) .body("error", nullValue()) .body("message", equalTo("Logout successfully")) @@ -97,4 +97,37 @@ public void logout() { .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/UsersSystemTests.java b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/UsersSystemTests.java new file mode 100644 index 0000000..b4704d1 --- /dev/null +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/UsersSystemTests.java @@ -0,0 +1,175 @@ +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.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; +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"); + + driver = new ChromeDriver(options); + wait = new WebDriverWait(driver, Duration.ofSeconds(10)); + } + + @AfterEach + public void teardown() { + if (driver != null) { + driver.quit(); + } + } + + @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"); + + wait.until(ExpectedConditions.elementToBeClickable(By.name("login-button"))).click(); + + 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"); + + wait.until(ExpectedConditions.elementToBeClickable(By.name("login-button"))).click(); + + 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"); + + wait.until(ExpectedConditions.elementToBeClickable(By.name("login-button"))).click(); + + 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"); + + wait.until(ExpectedConditions.elementToBeClickable(By.name("login-button"))).click(); + + 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); + + wait.until(ExpectedConditions.elementToBeClickable(By.name("login-button"))).click(); + + 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 index 6766872..e2b341b 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/UsersIntegrationTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/integration/UsersIntegrationTests.java @@ -73,4 +73,19 @@ void createUser() { } + @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 index bc4ce87..0f97f7d 100644 --- a/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/UsersUnitTests.java +++ b/backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/unit/UsersUnitTests.java @@ -11,9 +11,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.util.List; import java.util.NoSuchElementException; -import java.util.Optional; import es.codeurjcstudents.pcmod.model.User; import es.codeurjcstudents.pcmod.repository.UsersRepository; @@ -42,4 +40,36 @@ public void testCreateUser() { } + @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..b02c323 100644 --- a/frontend/app/app.css +++ b/frontend/app/app.css @@ -346,4 +346,122 @@ 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 .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 0 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/dtos/UserDTO.ts b/frontend/app/dtos/UserDTO.ts new file mode 100644 index 0000000..6d3e4af --- /dev/null +++ b/frontend/app/dtos/UserDTO.ts @@ -0,0 +1,8 @@ +export default interface UserDTO { + id: number; + name: string; + surname: string; + username: string; + address: string; + email: string; +} \ No newline at end of file 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. +
+
+
+ +

¿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/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/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 2893f43..6632fd9 100644 --- a/sonar-basic.properties +++ b/sonar-basic.properties @@ -8,6 +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 053b9f5..8dced39 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -8,6 +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/**,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 From e2e27580c03f02f4568c4006208fb46102c9a253 Mon Sep 17 00:00:00 2001 From: jaimeeg5 Date: Mon, 14 Sep 2026 13:56:55 +0200 Subject: [PATCH 4/4] feat: add login and role-based user options to header --- .../pcmod/e2e/ui/ComponentsSystemTests.java | 21 ++- .../pcmod/e2e/ui/HeaderSystemTests.java | 145 ++++++++++++++++ .../pcmod/e2e/ui/HomePageSystemTests.java | 20 ++- .../pcmod/e2e/ui/UsersSystemTests.java | 23 ++- frontend/app/app.css | 156 +++++++++++++++++- frontend/app/components/header.tsx | 82 ++++++++- frontend/app/dtos/UserDTO.ts | 1 + frontend/app/root.tsx | 1 + frontend/public/empty-profile-image.jpg | Bin 0 -> 18381 bytes .../integration/Header.integration.test.tsx | 91 ++++++++++ frontend/tests/unit/Header.test.tsx | 141 ++++++++++++++++ 11 files changed, 656 insertions(+), 25 deletions(-) create mode 100644 backend/pcmod/src/test/java/es/codeurjcstudents/pcmod/e2e/ui/HeaderSystemTests.java create mode 100644 frontend/public/empty-profile-image.jpg create mode 100644 frontend/tests/integration/Header.integration.test.tsx create mode 100644 frontend/tests/unit/Header.test.tsx 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 index b4704d1..30edceb 100644 --- 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 @@ -12,8 +12,10 @@ 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; @@ -33,6 +35,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 +48,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 loginRenderTest() { @@ -78,7 +91,7 @@ public void loginVoidCredentialsTest() { driver.get("http://localhost:5173/login"); - wait.until(ExpectedConditions.elementToBeClickable(By.name("login-button"))).click(); + scrollToAndClick(By.name("login-button")); assertThat(driver.findElement(By.id("invalid-email")).isDisplayed()).isTrue(); assertThat(driver.findElement(By.id("invalid-email")).getText()) @@ -100,7 +113,7 @@ public void loginValidCredentialsTest() { driver.findElement(By.id("email")).sendKeys("user@example.com"); driver.findElement(By.id("password")).sendKeys("userpass"); - wait.until(ExpectedConditions.elementToBeClickable(By.name("login-button"))).click(); + scrollToAndClick(By.name("login-button")); assertThat(driver.findElement(By.id("invalid-email")).isDisplayed()).isFalse(); assertThat(driver.findElement(By.id("invalid-password")).isDisplayed()).isFalse(); @@ -119,7 +132,7 @@ public void loginInvalidEmailFormatTest() { driver.findElement(By.id("email")).sendKeys("user"); driver.findElement(By.id("password")).sendKeys("userpass"); - wait.until(ExpectedConditions.elementToBeClickable(By.name("login-button"))).click(); + scrollToAndClick(By.name("login-button")); assertThat(driver.findElement(By.id("invalid-email")).isDisplayed()).isTrue(); assertThat(driver.findElement(By.id("invalid-email")).getText()) @@ -138,7 +151,7 @@ public void loginInvalidPassFormatTest() { driver.findElement(By.id("email")).sendKeys("user@example.com"); driver.findElement(By.id("password")).sendKeys("pass"); - wait.until(ExpectedConditions.elementToBeClickable(By.name("login-button"))).click(); + scrollToAndClick(By.name("login-button")); assertThat(driver.findElement(By.id("invalid-password")).isDisplayed()).isTrue(); assertThat(driver.findElement(By.id("invalid-password")).getText()) @@ -162,7 +175,7 @@ public void loginWrongCredentialsTest(String testName, String email, String pass driver.findElement(By.id("email")).sendKeys(email); driver.findElement(By.id("password")).sendKeys(password); - wait.until(ExpectedConditions.elementToBeClickable(By.name("login-button"))).click(); + scrollToAndClick(By.name("login-button")); wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("error-message"))); assertThat(driver.findElement(By.id("error-message")).getText()) diff --git a/frontend/app/app.css b/frontend/app/app.css index b02c323..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; @@ -407,6 +547,14 @@ body { 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; @@ -430,7 +578,7 @@ body { .error-message-text { margin-bottom: 1.5rem; - padding: 1rem 1rem 0 0.75rem; + padding: 1rem 1rem 1rem 0.75rem; color: var(--color-brand-dark); background-color: #f9dcca; border: 1px solid #ff0404; 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 ( - ; + ); } \ No newline at end of file diff --git a/frontend/app/dtos/UserDTO.ts b/frontend/app/dtos/UserDTO.ts index 6d3e4af..0f8d045 100644 --- a/frontend/app/dtos/UserDTO.ts +++ b/frontend/app/dtos/UserDTO.ts @@ -5,4 +5,5 @@ export default interface UserDTO { 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/public/empty-profile-image.jpg b/frontend/public/empty-profile-image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..31df1b79f43cfe509201f05aa8a5b2d09599385f GIT binary patch literal 18381 zcmeHubzGEB*Y~w7-QBS)N_Tfi2uL?3-My56u!OWUNGc)SAV^4qAR$tUf=YuD0!k|I z?k*U=`@Wy&^S+85CHXRqq#VXg0{Wnk&&XenU@m6OGl_LcN?c5}A& zG>7;)JGsClePy61_z)fMF`9~1()8275R zr{`TYXLDO?*i|(ZgWpyVR(WlaGJBK^6>wX78_$1?Z)N#g-_6Uz2{o#fC7-pEwX?O0 zCk&x0fEemLx^9RB|JFwM<^{7xLa07YRyKTy0WWM{bU^x{?28D|MY#W?UkC+0L4JO5 z9)3X{L5bgkA&lwS+uC{lW5j=7IUXn-M6ciBl~nYwwz5YIii!*5JTOqmzeGt=0S2@7 zR8x|H3J4(v7Zwo}F{q@mwS~SXVgn$fa6W$+V(}u^i#^QK)zT3)94e$v*0$zO zd`|Y3)-Et>9&=j{Yg8vWFHh$?&WM?KIm;l{m${3*C9Ao!n-mg;xq5k6TFcnFy4pHf z|3)nlVsm?27a3%j5V=73gov)BlDVh3roD^3i|y|?{)gCmyIPuC@F{7k@#yGjtEyd5 zGf-CIQ8rOj)-ljmR?t&aRo1$yrlqW=rKD`4rgaqxLF^tYOG!%)Yjecrl|}6leQWn~ z!pUA%$J*1{L&w_1(%wm$54qS8*g1jH;`_TVg0}jzHO@^aIC=iEg z+n9Sfc|v9Xzn(Y$GDXzj$SM9kEnI0-I5kj-`VTInwzQkM2h7^wuA8+Co1XP~n%HFj z+0jr>Ll(K7|Eu+YS{c9BgRS$Q^~Li)UlY=N|8N31mw)m7JU#!=LLFeJB>r`RArD3r zhBz4!Yk}|gQHo%GAI8=$h!fZcdDNb50_i%x4mgMqFFzoDMNJbO9rf(+rw3gJz;u_d z(xd4Bcz-P&dNf}^^Z#q6>Cs#O{J(cLa5e)d0=QUM*jSjj*x1;3xVU(P)Wn1Y1cVF} zlqA$FOqVY~nV`(9Tw;8z9KsyTP<{mgVF@W&S=r0HN@|MIs$w#-Qb;2pTs%BN0z!IX zVtOfdD7)1Eb2;kjOv@GbmpWlC_L4>_4z%s&O&~qEOmqpU# zN~)B<`X6<%!c5TYFJi`#|HeDU^`SjDx@fXrCI(La^CtH>6MyZi`8QP z4TC|NstzAw#y8==AtWKq$#JuC`gzsd9~9hOlX`uWs_)Yu2thGYkXIR`+z#1}Ddj@)Q$tB!()u$NCOql!Ztd5vDR!+d;KAfFv-##xWe-#f`tEtx zTONtLm1szE4@C7tvbYY$>v`88ND}Mi2zM`%0Dv3N;ap_P{u%EU^cIRlvfC?B>1*XE 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 { + + 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/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