diff --git a/.env.example b/.env.example index 750ed50..6c2d0d2 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ POSTGRES_DB=p2p_shopping POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres \ No newline at end of file +POSTGRES_PASSWORD=postgres +JWT_SECRET=your-secret-key-here-at-least-32-characters-long \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 4609653..d2ba82d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,7 +21,7 @@ repositories { } val springdocVersion = "3.0.2" - +val jjwtVersion = "0.13.0" dependencies { annotationProcessor("org.projectlombok:lombok") @@ -32,11 +32,23 @@ dependencies { implementation("org.projectlombok:lombok") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("io.jsonwebtoken:jjwt-api:${jjwtVersion}") + + testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + + testImplementation("com.h2database:h2") + runtimeOnly("io.jsonwebtoken:jjwt-impl:${jjwtVersion}") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:${jjwtVersion}") runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.junit.platform:junit-platform-suite-api") + testImplementation("org.testcontainers:testcontainers:1.19.0") + testImplementation("org.testcontainers:postgresql:1.19.0") testRuntimeOnly("org.junit.platform:junit-platform-suite-engine") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/docker-compose.yml b/docker-compose.yml index f386b4e..a42ee5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: db: image: postgis/postgis:16-3.4 @@ -10,7 +8,7 @@ services: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ports: - - "5432:5432" + - "5433:5432" volumes: - pgdata:/var/lib/postgresql/data - ./init-scripts:/docker-entrypoint-initdb.d diff --git a/init-scripts/users.sql b/init-scripts/users.sql new file mode 100644 index 0000000..ed58ddf --- /dev/null +++ b/init-scripts/users.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); \ No newline at end of file diff --git a/src/main/java/com/p2ps/auth/controller/AuthController.java b/src/main/java/com/p2ps/auth/controller/AuthController.java new file mode 100644 index 0000000..ca96e19 --- /dev/null +++ b/src/main/java/com/p2ps/auth/controller/AuthController.java @@ -0,0 +1,57 @@ +package com.p2ps.auth.controller; + +import com.p2ps.auth.security.dto.LoginRequest; +import com.p2ps.auth.dto.RegisterRequest; +import com.p2ps.auth.security.JwtUtil; +import com.p2ps.auth.service.UserService; +import jakarta.validation.Valid; +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.web.bind.annotation.*; + +import java.util.Collections; +import java.util.Map; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { // Acolada clasei deschisă aici + + private final UserService userService; + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; + + public AuthController(UserService userService, AuthenticationManager authenticationManager, JwtUtil jwtUtil) { + this.userService = userService; + this.authenticationManager = authenticationManager; + this.jwtUtil = jwtUtil; + } + + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { + userService.registerUser( + request.getEmail(), + request.getPassword(), + request.getFirstName(), + request.getLastName() + ); + return ResponseEntity.ok(Map.of("message", "User registered successfully!")); + } + + @PostMapping("/login") + public ResponseEntity> login(@Valid @RequestBody LoginRequest request) { + + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + request.getEmail(), + request.getPassword() + ) + ); + + + String token = jwtUtil.generateToken(request.getEmail()); + + return ResponseEntity.ok(Collections.singletonMap("token", token)); + } +} \ No newline at end of file diff --git a/src/main/java/com/p2ps/auth/dto/RegisterRequest.java b/src/main/java/com/p2ps/auth/dto/RegisterRequest.java new file mode 100644 index 0000000..a314f17 --- /dev/null +++ b/src/main/java/com/p2ps/auth/dto/RegisterRequest.java @@ -0,0 +1,38 @@ +package com.p2ps.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + + + +import lombok.*; + +@Getter +@Setter +public class RegisterRequest { + + @NotBlank(message = "First name is required") + @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") + @Pattern(regexp = "^[a-zA-Z\\s-]+$", message = "First name can only contain letters, spaces, or hyphens") + private String firstName; + + @NotBlank(message = "Last name is required") + @Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters") + @Pattern(regexp = "^[a-zA-Z\\s-]+$", message = "Last name can only contain letters, spaces, or hyphens") + private String lastName; + + @NotBlank(message = "Email is required") + @Email(message = "Invalid email format") + @Size(max = 255) + private String email; + + @NotBlank(message = "Password is required") + @Size(min = 8, max = 100) + @Pattern(regexp = "^(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}$", + message = "Password must contain at least one digit, one lowercase letter, and one uppercase letter") + private String password; + + +} \ No newline at end of file diff --git a/src/main/java/com/p2ps/auth/model/Users.java b/src/main/java/com/p2ps/auth/model/Users.java new file mode 100644 index 0000000..33f02da --- /dev/null +++ b/src/main/java/com/p2ps/auth/model/Users.java @@ -0,0 +1,40 @@ +package com.p2ps.auth.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "users") +public class Users { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String password; + + + public Users() {} + @Column(name = "first_name", nullable = false) + private String firstName; + + @Column(name = "last_name", nullable = false) + private String lastName; + + public Users(String email, String password, String firstName, String lastName) { + this.email = email; + this.password = password; + this.firstName = firstName; + this.lastName = lastName; + } + + + +} \ No newline at end of file diff --git a/src/main/java/com/p2ps/auth/repository/UserRepository.java b/src/main/java/com/p2ps/auth/repository/UserRepository.java new file mode 100644 index 0000000..c1b53ae --- /dev/null +++ b/src/main/java/com/p2ps/auth/repository/UserRepository.java @@ -0,0 +1,10 @@ +package com.p2ps.auth.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import com.p2ps.auth.model.Users; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByEmail(String email); +} \ No newline at end of file diff --git a/src/main/java/com/p2ps/auth/security/JwtAuthFilter.java b/src/main/java/com/p2ps/auth/security/JwtAuthFilter.java new file mode 100644 index 0000000..f3ba0de --- /dev/null +++ b/src/main/java/com/p2ps/auth/security/JwtAuthFilter.java @@ -0,0 +1,62 @@ +package com.p2ps.auth.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; + +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + public JwtAuthFilter(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Override + public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String token = null; + String userEmail = null; + + // Extract token from Authorization header + String authorizationHeader = request.getHeader("Authorization"); + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + token = authorizationHeader.substring(7); + } + + try { + if (token != null) { + userEmail = jwtUtil.extractEmail(token); + } + + // Authenticate if user is not already in the SecurityContext + if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null && !jwtUtil.isTokenExpired(token)){ + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userEmail, null, new ArrayList<>() + ); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + // Set user as authenticated + SecurityContextHolder.getContext().setAuthentication(authToken); + } + + } catch (Exception _) { + // Clear context if token is expired, malformed, or invalid + SecurityContextHolder.clearContext(); + } + + // Continue the filter chain + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/p2ps/auth/security/JwtUtil.java b/src/main/java/com/p2ps/auth/security/JwtUtil.java new file mode 100644 index 0000000..c62b72d --- /dev/null +++ b/src/main/java/com/p2ps/auth/security/JwtUtil.java @@ -0,0 +1,67 @@ +package com.p2ps.auth.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secretKeyString; + + private SecretKey secretKey; + + private static final long EXPIRATION_TIME = 1000L * 60 * 60 * 24; // 24h + + @PostConstruct + public void init() { + + byte[] keyBytes = secretKeyString == null + ? new byte[0] + : secretKeyString.getBytes(StandardCharsets.UTF_8); + + if (keyBytes.length < 32) { + throw new IllegalStateException("JWT secret must be at least 32 bytes for HS256"); + } + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + public String generateToken(String email) { + + return Jwts.builder() + .subject(email) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) + .signWith(secretKey) + .compact(); + } + + public String extractEmail(String token) { + return extractAllClaims(token).getSubject(); + } + + public boolean isTokenExpired(String token) { + return extractAllClaims(token).getExpiration().before(new Date()); + } + + public boolean isTokenValid(String token, String userEmailFromDatabase) { + final String email = extractEmail(token); + return (email.equals(userEmailFromDatabase) && !isTokenExpired(token)); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} \ No newline at end of file diff --git a/src/main/java/com/p2ps/auth/security/SecurityConfig.java b/src/main/java/com/p2ps/auth/security/SecurityConfig.java new file mode 100644 index 0000000..033ac6b --- /dev/null +++ b/src/main/java/com/p2ps/auth/security/SecurityConfig.java @@ -0,0 +1,80 @@ +package com.p2ps.auth.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +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 org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import com.p2ps.auth.service.*; + +import java.util.Arrays; +import java.util.List; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JwtAuthFilter jwtAuthFilter; + + public SecurityConfig(JwtAuthFilter jwtAuthFilter) { + this.jwtAuthFilter = jwtAuthFilter; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean + public AuthenticationManager authenticationManager(HttpSecurity http, PasswordEncoder passwordEncoder, UserService userService) throws Exception { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userService); + authProvider.setPasswordEncoder(passwordEncoder); + + return new ProviderManager(authProvider); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, response, authException) -> { + response.setContentType("application/json"); + response.setStatus(401); + response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"" + authException.getMessage() + "\"}"); + }) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .anyRequest().authenticated() + ) + .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("http://localhost:5173")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Accept")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} \ No newline at end of file diff --git a/src/main/java/com/p2ps/auth/security/dto/AuthResponse.java b/src/main/java/com/p2ps/auth/security/dto/AuthResponse.java new file mode 100644 index 0000000..a336f28 --- /dev/null +++ b/src/main/java/com/p2ps/auth/security/dto/AuthResponse.java @@ -0,0 +1,12 @@ +package com.p2ps.auth.security.dto; + + +import lombok.Getter; + +@Getter +public class AuthResponse { + private final String token; + + public AuthResponse(String token) { this.token = token; } + +} \ No newline at end of file diff --git a/src/main/java/com/p2ps/auth/security/dto/LoginRequest.java b/src/main/java/com/p2ps/auth/security/dto/LoginRequest.java new file mode 100644 index 0000000..8804265 --- /dev/null +++ b/src/main/java/com/p2ps/auth/security/dto/LoginRequest.java @@ -0,0 +1,18 @@ +package com.p2ps.auth.security.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.*; +@Getter +@Setter +@Data +public class LoginRequest { + @NotBlank(message = "Email is required") + @Email(message = "Invalid email format") + private String email; + + @NotBlank(message = "Password is required") + private String password; + + +} \ No newline at end of file diff --git a/src/main/java/com/p2ps/auth/service/UserService.java b/src/main/java/com/p2ps/auth/service/UserService.java new file mode 100644 index 0000000..7896e5a --- /dev/null +++ b/src/main/java/com/p2ps/auth/service/UserService.java @@ -0,0 +1,54 @@ +package com.p2ps.auth.service; + +import com.p2ps.auth.model.Users; +import com.p2ps.auth.repository.UserRepository; +import com.p2ps.exception.UserAlreadyExistsException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class UserService implements UserDetailsService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + + Users user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); + + + return User.builder() + .username(user.getEmail()) + .password(user.getPassword()) + .roles("USER") + .build(); + } + + @Transactional + public Users registerUser(String email, String rawPassword, String firstName, String lastName) { + if (userRepository.existsByEmail(email)) { + throw new UserAlreadyExistsException("Email already in use!"); + } + + try { + String hashedPassword = passwordEncoder.encode(rawPassword); + Users newUser = new Users(email, hashedPassword, firstName, lastName); + return userRepository.save(newUser); + } catch (DataIntegrityViolationException _) { + throw new UserAlreadyExistsException("Email already in use!"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/p2ps/exception/GlobalExceptionHandler.java b/src/main/java/com/p2ps/exception/GlobalExceptionHandler.java index e02fe4e..7328ed3 100644 --- a/src/main/java/com/p2ps/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/p2ps/exception/GlobalExceptionHandler.java @@ -2,11 +2,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.util.Map; + // Catches all exceptions and returns a clean JSON response @RestControllerAdvice public class GlobalExceptionHandler { @@ -14,6 +20,30 @@ public class GlobalExceptionHandler { // Logger used to record internal errors secretly on the server private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(UserAlreadyExistsException.class) + public ResponseEntity handleUserAlreadyExists(UserAlreadyExistsException ex) { + ErrorResponse errorResponse = new ErrorResponse( + "Registration Failed", + ex.getMessage() + ); + return new ResponseEntity<>(errorResponse, HttpStatus.CONFLICT); // 409 Conflict + } + + // Prinde erorile de la @Valid (ex: parola prea scurta) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationErrors(MethodArgumentNotValidException ex) { + // Extragem primul mesaj de eroare definit in DTO + String errorMessage = ex.getBindingResult().getAllErrors().stream() + .findFirst() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .orElse("Validation failed"); + + ErrorResponse errorResponse = new ErrorResponse( + "Validation Error", + errorMessage + ); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); // 400 Bad Request + } @ExceptionHandler(Exception.class) public ResponseEntity handleGlobalException(Exception ex) { //Log the full error internally (for the backend team to see) @@ -27,4 +57,14 @@ public ResponseEntity handleGlobalException(Exception ex) { return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); } + + @ExceptionHandler({BadCredentialsException.class, UsernameNotFoundException.class}) + public ResponseEntity> handleAuthenticationError(Exception ex) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) // Trimitem 401 în loc de 500 + .body(Map.of( + "error", "Unauthorized", + "message", "Invalid email or password" // Mesaj generic, sigur + )); + } } diff --git a/src/main/java/com/p2ps/exception/UserAlreadyExistsException.java b/src/main/java/com/p2ps/exception/UserAlreadyExistsException.java new file mode 100644 index 0000000..c56dca2 --- /dev/null +++ b/src/main/java/com/p2ps/exception/UserAlreadyExistsException.java @@ -0,0 +1,7 @@ +package com.p2ps.exception; + +public class UserAlreadyExistsException extends RuntimeException { + public UserAlreadyExistsException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c27ee1e..a965e7d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,16 +1,15 @@ spring.application.name=P2P-Shopping - -spring.datasource.url=jdbc:postgresql://localhost:5432/p2p_shopping -spring.datasource.username=postgres -spring.datasource.password=postgres +server.port=8081 +spring.config.import=optional:file:.env[.properties] +spring.datasource.url=jdbc:postgresql://localhost:5433/${POSTGRES_DB} +spring.datasource.username=${POSTGRES_USER} +spring.datasource.password=${POSTGRES_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver +jwt.secret=${JWT_SECRET:defaultDevSecretKeyThatIsAtLeast32BytesLong!!} -spring.jpa.hibernate.ddl-auto=update +spring.jpa.hibernate.ddl-auto=validate spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -app.cors.allowed-origins=${APP_CORS_ALLOWED_ORIGINS:http://localhost:*} -server.port=8081 -spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration -spring.mongodb.uri=mongodb://localhost:27017/p2p_shopping +app.cors.allowed-origins=http://localhost:5173 \ No newline at end of file diff --git a/src/test/java/com/p2ps/P2PShoppingApplicationTests.java b/src/test/java/com/p2ps/P2PShoppingApplicationTests.java index d983cc0..963271f 100644 --- a/src/test/java/com/p2ps/P2PShoppingApplicationTests.java +++ b/src/test/java/com/p2ps/P2PShoppingApplicationTests.java @@ -2,21 +2,21 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest(properties = { - "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration,org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration,org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration,org.springframework.boot.autoconfigure.data.mongo.MongoRepositoriesAutoConfiguration" + "jwt.secret=test-secret-key-care-trebuie-sa-fie-foarte-lunga-32-chars", + "spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop" }) +@ActiveProfiles("test") class P2PShoppingApplicationTests { - @Test void contextLoads() { - } - @Test - void shouldStartApplicationMainWithoutThrowing() { - assertDoesNotThrow(() -> P2PShoppingApplication.main(new String[]{"--spring.main.web-application-type=none"})); } - -} +} \ No newline at end of file diff --git a/src/test/java/com/p2ps/auth/AuthControllerTest.java b/src/test/java/com/p2ps/auth/AuthControllerTest.java new file mode 100644 index 0000000..b980980 --- /dev/null +++ b/src/test/java/com/p2ps/auth/AuthControllerTest.java @@ -0,0 +1,146 @@ +package com.p2ps.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.p2ps.auth.dto.RegisterRequest; +import com.p2ps.auth.model.Users; +import com.p2ps.auth.security.JwtUtil; +import com.p2ps.auth.security.dto.LoginRequest; +import com.p2ps.auth.service.UserService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "jwt.secret=test-secret-key-care-trebuie-sgdughghfyufdhgisjaLEWjroihesiutheroijgtrhyjktrnhjgdfngui54y645t785htguh3uhath4ruhtrsdnfkjzrenrwewnfwekwa-fie-foarte-lunga-32-chars", + "spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + private ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean + private UserService userService; + + @MockitoBean + private AuthenticationManager authenticationManager; + + @MockitoBean + private JwtUtil jwtUtil; + + + @Test + void register_ShouldReturnCreated() throws Exception { + RegisterRequest request = new RegisterRequest(); + request.setEmail("new@example.com"); + request.setPassword("Password123!"); + request.setFirstName("John"); + request.setLastName("Doe"); + + when(userService.registerUser(anyString(), anyString(), anyString(), anyString())) + .thenReturn(new Users()); + + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + void register_ShouldReturnBadRequest_WhenValidationFails() throws Exception { + RegisterRequest request = new RegisterRequest(); + request.setEmail("email_invalid"); + request.setPassword(""); + + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + void login_ShouldReturnOk() throws Exception { + LoginRequest request = new LoginRequest(); + request.setEmail("test@example.com"); + request.setPassword("Password123!"); + + Authentication auth = new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()); + + when(authenticationManager.authenticate(any(Authentication.class))).thenReturn(auth); + when(jwtUtil.generateToken(anyString())).thenReturn("mocked-jwt-token-123"); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + void login_ShouldReturnUnauthorized_WhenCredentialsAreInvalid() throws Exception { + LoginRequest request = new LoginRequest(); + request.setEmail("wrong@example.com"); + request.setPassword("wrongpass"); + + when(authenticationManager.authenticate(any())) + .thenThrow(new org.springframework.security.authentication.BadCredentialsException("Bad credentials")); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); // Aici verificam ca returneaza 401 + } + + @Test + void register_ShouldReturnConflict_WhenEmailAlreadyExists() throws Exception { + RegisterRequest request = new RegisterRequest(); + request.setEmail("existent@example.com"); + request.setPassword("Password123!"); + request.setFirstName("Ion"); + request.setLastName("Popescu"); + + when(userService.registerUser(anyString(), anyString(), anyString(), anyString())) + .thenThrow(new com.p2ps.exception.UserAlreadyExistsException("Email already in use!")); + + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); // Testăm ramura de 409 + } + + @Test + void register_ShouldReturnBadRequest_WhenNamesAreBlank() throws Exception { + RegisterRequest request = new RegisterRequest(); + request.setEmail("valid@email.com"); + request.setPassword("Password123!"); + request.setFirstName(""); // Blank + request.setLastName(""); // Blank + + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } +} \ No newline at end of file diff --git a/src/test/java/com/p2ps/auth/JwtAuthFilterTest.java b/src/test/java/com/p2ps/auth/JwtAuthFilterTest.java new file mode 100644 index 0000000..ed14824 --- /dev/null +++ b/src/test/java/com/p2ps/auth/JwtAuthFilterTest.java @@ -0,0 +1,74 @@ +package com.p2ps.auth; + + +import com.p2ps.auth.security.JwtAuthFilter; +import com.p2ps.auth.security.JwtUtil; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + + +@SpringBootTest(properties = { + "jwt.secret=test-secret-key-care-trebuie-sa-fie-foar vhjcbfvifdbvishfiuhsiufhsuhfwa4yr78e2hfhdsiuncfjsdbhcsbdzhHbhcsdvsdfsffzvfvsaklmdl$%cjsdnfjnsjfnsjnfesf$^%$^fgjnenzskrgerte-lunga-32-chars", + "spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +@ExtendWith(MockitoExtension.class) +class JwtAuthFilterTest { + + @Mock + private JwtUtil jwtUtil; + + @Mock + private FilterChain filterChain; + + @InjectMocks + private JwtAuthFilter jwtAuthFilter; + + @Test + void doFilterInternal_WithValidTokenInHeader() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + request.addHeader("Authorization", "Bearer valid-jwt"); + + when(jwtUtil.extractEmail("valid-jwt")).thenReturn("test@test.com"); + when(jwtUtil.isTokenExpired("valid-jwt")).thenReturn(false); + + jwtAuthFilter.doFilterInternal(request, response, filterChain); + + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + assertEquals("test@test.com", SecurityContextHolder.getContext().getAuthentication().getPrincipal()); + verify(filterChain).doFilter(request, response); + } + + @Test + void doFilterInternal_NoHeader_ShouldContinueChain() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + SecurityContextHolder.clearContext(); + + jwtAuthFilter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain).doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/test/java/com/p2ps/auth/JwtUtilTest.java b/src/test/java/com/p2ps/auth/JwtUtilTest.java new file mode 100644 index 0000000..2c7c910 --- /dev/null +++ b/src/test/java/com/p2ps/auth/JwtUtilTest.java @@ -0,0 +1,59 @@ +package com.p2ps.auth; + + + +import com.p2ps.auth.security.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import static org.junit.jupiter.api.Assertions.*; + + +@SpringBootTest(properties = { + "jwt.secret=test-secret-key-care-trebuie-sa-fiefnkfjsfdssfffffffffffffiejwroiwjeoiraaaoiiojreijfierjfrengjkrengfdrnjkger-foarte-lunga-32-chars", + "spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class JwtUtilTest { + + private JwtUtil jwtUtil; + private final String secret = "v8yB2p5xhfsbfbhjsbdvfhbjhfdgyurgygfeugfrfdrreyfbrdfbhdbfhdfbhrdbfhrsbfsgfiufzikdhfuisdzfuhiusdhfddddddddddddddddddddddddddddddddddhrfuv8A/D?G-KaPdSgVkYp3s6v9y$B&E)H+MbQeThWmZq4t7w!z%C*F-JaNcR"; + + @BeforeEach + void setUp() { + jwtUtil = new JwtUtil(); + ReflectionTestUtils.setField(jwtUtil, "secretKeyString", secret); + jwtUtil.init(); + } + + @Test + void generateAndExtractEmail() { + String email = "user@test.com"; + String token = jwtUtil.generateToken(email); + + assertNotNull(token); + assertEquals(email, jwtUtil.extractEmail(token)); + } + + @Test + void validateToken_Success() { + String email = "user@test.com"; + String token = jwtUtil.generateToken(email); + assertTrue(jwtUtil.isTokenValid(token, email)); + } + + @Test + void validateToken_Fail_WrongUser() { + String token = jwtUtil.generateToken("user1@test.com"); + assertFalse(jwtUtil.isTokenValid(token, "user2@test.com")); + } +} \ No newline at end of file diff --git a/src/test/java/com/p2ps/auth/UserSeriviceTest.java b/src/test/java/com/p2ps/auth/UserSeriviceTest.java new file mode 100644 index 0000000..edc3522 --- /dev/null +++ b/src/test/java/com/p2ps/auth/UserSeriviceTest.java @@ -0,0 +1,95 @@ +package com.p2ps.auth; + +import com.p2ps.auth.model.Users; +import com.p2ps.auth.repository.UserRepository; +import com.p2ps.auth.service.UserService; +import com.p2ps.exception.UserAlreadyExistsException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UserService userService; + + private final String email = "test@example.com"; + private final String password = "Password123"; + private final String firstName = "Andrei"; + private final String lastName = "Popescu"; + + @Test + void registerUser_Success() { + when(userRepository.existsByEmail(email)).thenReturn(false); + when(passwordEncoder.encode(password)).thenReturn("hashedPassword"); + when(userRepository.save(any(Users.class))).thenAnswer(i -> i.getArguments()[0]); + + Users savedUser = userService.registerUser(email, password, firstName, lastName); + + assertNotNull(savedUser); + assertEquals(email, savedUser.getEmail()); + verify(userRepository, times(1)).save(any(Users.class)); + } + + @Test + void registerUser_ThrowsException_WhenEmailExists() { + when(userRepository.existsByEmail(email)).thenReturn(true); + + assertThrows(UserAlreadyExistsException.class, () -> { + userService.registerUser(email, password, firstName, lastName); + }); + + verify(userRepository, never()).save(any(Users.class)); + } + + @Test + void registerUser_HandlesDataIntegrityViolation() { + when(userRepository.existsByEmail(email)).thenReturn(false); + when(passwordEncoder.encode(password)).thenReturn("hashed"); + when(userRepository.save(any(Users.class))).thenThrow(new DataIntegrityViolationException("Duplicate")); + + assertThrows(UserAlreadyExistsException.class, () -> { + userService.registerUser(email, password, firstName, lastName); + }); + } + + @Test + void loadUserByUsername_Success() { + Users user = new Users(email, "hashedPassword", firstName, lastName); + when(userRepository.findByEmail(email)).thenReturn(Optional.of(user)); + + UserDetails userDetails = userService.loadUserByUsername(email); + + assertNotNull(userDetails); + assertEquals(email, userDetails.getUsername()); + } + + @Test + void loadUserByUsername_ThrowsException_WhenUserNotFound() { + when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + + assertThrows(UsernameNotFoundException.class, () -> { + userService.loadUserByUsername("nonexistent@example.com"); + }); + } +} \ No newline at end of file diff --git a/src/test/java/resources/application-test.properties b/src/test/java/resources/application-test.properties new file mode 100644 index 0000000..f7c4e57 --- /dev/null +++ b/src/test/java/resources/application-test.properties @@ -0,0 +1,16 @@ +spring.application.name=P2P-Shopping + +# H2 Database Configuration - Perfect pentru CI/Sonar +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +# Hibernate settings +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect + +spring.data.mongodb.uri=mongodb://mock-address:27017/test + +server.port=8081 \ No newline at end of file