diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ded242..efe71cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: test: runs-on: ubuntu-latest env: - JWT_SECRET: ci-only-secret-not-for-production-use-0123456789 + JWT_SECRET: Y2ktb25seS1qd3Qtc2VjcmV0LWZvci1yZXN0b3Jlc2VydmUtdGVzdHMtMDEyMzQ1Njc4OQ== steps: - uses: actions/checkout@v7 - uses: actions/setup-java@v5 diff --git a/README.md b/README.md index 062a0ce..da2c748 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # RestoReserve API -[![CI](https://github.com/cloudxdam/restoreserve-api/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/cloudxdam/restoreserve-api/actions/workflows/ci.yml) +[![CI](https://github.com/cloudxdam/restoreserve-api/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/cloudxdam/restoreserve-api/actions/workflows/ci.yml) RestoReserve API is a Spring Boot REST API for managing restaurant tables, reservations, and user access with JWT authentication. @@ -79,11 +79,11 @@ The reservation service implements several domain rules: ### Reservations -- `POST /api/v1/reservations` — create a reservation for the authenticated user -- `GET /api/v1/reservations` — list reservations accessible to the current user -- `GET /api/v1/reservations/{id}` — get a reservation by ID -- `GET /api/v1/reservations/status/{status}` — filter reservations by status -- `DELETE /api/v1/reservations/{id}` — cancel a reservation +- `POST /api/v1/reservations` — create a reservation for the authenticated user (`USER`, `ADMIN`) +- `GET /api/v1/reservations` — list reservations accessible to the current user (`USER`, `ADMIN`) +- `GET /api/v1/reservations/{id}` — get a reservation by ID (`USER`, `ADMIN`) +- `GET /api/v1/reservations/status/{status}` — filter reservations by status (`USER`, `ADMIN`) +- `DELETE /api/v1/reservations/{id}` — cancel a reservation (`USER`, `ADMIN`) ### Restaurant Tables @@ -246,8 +246,9 @@ The project uses: - Mockito - Spring Boot Test - Spring Security Test +- MockMvc -Current tests cover application context loading and reservation-service business rules, including validation, banned users, reservation ownership, cancellation, penalization, and automatic banning. +Current tests cover three layers: the application context, reservation-service business rules (validation, banned users, reservation ownership, cancellation, penalization, and automatic banning), and the HTTP layer through MockMvc with real JWTs (login and registration responses, failed-login status codes, role-based access control, per-user data isolation, and validation errors). Run the test suite with: @@ -264,7 +265,6 @@ Run the test suite with: ## Future Improvements -- Add more controller, security, and integration tests. - Containerize the API with a Dockerfile and an `app` service in compose. - Replace `ddl-auto=update` with Flyway migrations. - Add production-oriented configuration and observability. @@ -284,7 +284,7 @@ It demonstrates practical backend concepts including: - JPA persistence - Business-rule implementation - Centralized exception handling -- Unit testing and mocking +- Unit and integration testing - API documentation with OpenAPI It provides a foundation for demonstrating junior backend development skills with Java and Spring Boot while continuing to evolve toward a more production-oriented architecture. diff --git a/src/main/java/com/pachedev/restoreserve/exception/GlobalExceptionHandler.java b/src/main/java/com/pachedev/restoreserve/exception/GlobalExceptionHandler.java index c799abb..00a2d06 100644 --- a/src/main/java/com/pachedev/restoreserve/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/pachedev/restoreserve/exception/GlobalExceptionHandler.java @@ -1,15 +1,16 @@ package com.pachedev.restoreserve.exception; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.Map; - import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + @RestControllerAdvice public class GlobalExceptionHandler { @@ -66,4 +67,15 @@ public ResponseEntity handleNotVip(NotVipUserException ex) { return new ResponseEntity<>(error, HttpStatus.FORBIDDEN); } + + /** + * Maneja fallos de autenticación (credenciales incorrectas) devolviendo 401 + * en lugar de 404/409, para no revelar si el usuario existe o no. + */ + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthentication(AuthenticationException ex) { + ErrorResponse error = new ErrorResponse("UNAUTHORIZED", ex.getMessage(), LocalDateTime.now()); + + return new ResponseEntity<>(error, HttpStatus.UNAUTHORIZED); + } } \ No newline at end of file diff --git a/src/main/java/com/pachedev/restoreserve/security/SecurityConfig.java b/src/main/java/com/pachedev/restoreserve/security/SecurityConfig.java index f9440ae..99bc2df 100644 --- a/src/main/java/com/pachedev/restoreserve/security/SecurityConfig.java +++ b/src/main/java/com/pachedev/restoreserve/security/SecurityConfig.java @@ -1,5 +1,7 @@ package com.pachedev.restoreserve.security; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -10,8 +12,6 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import lombok.RequiredArgsConstructor; - /** * Configuración de seguridad de la API: * - Define cómo se protegen los datos (hash). @@ -38,7 +38,7 @@ PasswordEncoder passwordEncoder() { /** * Cadena de filtros de Seguridad de la API, los controles automáticos por donde * pasará cada petición HTTP: - * + *

* 1. Desactiva CSRF (no la necesitamos, nuestra API es stateless gracias al * uso de tokens JWT). * 2. definimos que la política de sesión es stateless (no guardará la @@ -48,9 +48,13 @@ PasswordEncoder passwordEncoder() { * ADMIN. * 5. declaramos los endpoints a los que pueden acceder los que tengan rol USER * o ADMIN. - * 6. anyRequest().authenticated() .> cualquier otra ruta requiere usuario + * 6. anyRequest().authenticated() -> cualquier otra ruta requiere usuario * autenticado. - * 7. addFilterBefore -> antes de pasar el filtro de Spring Security debe pasar + * 7. exceptionHandling -> si una petición llega sin token devolvemos 401 (no + * autenticado); si llega autenticada pero con un rol insuficiente, 403 + * (acceso denegado), en lugar del 403 genérico que devuelve Spring Security + * por defecto para ambos casos. + * 8. addFilterBefore -> antes de pasar el filtro de Spring Security debe pasar * el que hemos definido nosotros, que leerá el token y autenticará al usuario */ @Bean @@ -66,6 +70,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { .hasRole("ADMIN") .requestMatchers("/api/v1/reservations/**").hasAnyRole("USER", "ADMIN") .anyRequest().authenticated()) + .exceptionHandling(exceptions -> exceptions.authenticationEntryPoint((request, response, authException) -> + response.sendError(HttpServletResponse.SC_UNAUTHORIZED))) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); } diff --git a/src/main/java/com/pachedev/restoreserve/service/AuthService.java b/src/main/java/com/pachedev/restoreserve/service/AuthService.java index 600249f..0f5fe8d 100644 --- a/src/main/java/com/pachedev/restoreserve/service/AuthService.java +++ b/src/main/java/com/pachedev/restoreserve/service/AuthService.java @@ -1,20 +1,18 @@ package com.pachedev.restoreserve.service; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - import com.pachedev.restoreserve.dto.AuthRequestDTO; import com.pachedev.restoreserve.dto.AuthResponseDTO; import com.pachedev.restoreserve.dto.RegisterUserRequestDTO; import com.pachedev.restoreserve.exception.BusinessLogicException; -import com.pachedev.restoreserve.exception.ResourceNotFoundException; import com.pachedev.restoreserve.model.entity.AppUser; import com.pachedev.restoreserve.model.enums.UserRole; import com.pachedev.restoreserve.model.enums.UserStatus; import com.pachedev.restoreserve.repository.AppUserRepository; import com.pachedev.restoreserve.security.JwtService; - import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; /** * Servicio encargado de autenticar usuarios mediante JWT. @@ -38,10 +36,10 @@ public class AuthService { public AuthResponseDTO login(AuthRequestDTO dto) { AppUser user = appUserRepository.findByUsername(dto.username()) - .orElseThrow(() -> new ResourceNotFoundException("Usuario " + dto.username() + " no encontrado")); + .orElseThrow(() -> new BadCredentialsException("Usuario o contraseña incorrectos")); if (!passwordEncoder.matches(dto.password(), (user.getPassword()))) { - throw new BusinessLogicException("Credenciales no válidas"); + throw new BadCredentialsException("Usuario o contraseña incorrectos"); } String token = jwtService.generateToken(user.getUsername()); diff --git a/src/test/java/com/pachedev/restoreserve/controller/AuthControllerTest.java b/src/test/java/com/pachedev/restoreserve/controller/AuthControllerTest.java new file mode 100644 index 0000000..7c6c294 --- /dev/null +++ b/src/test/java/com/pachedev/restoreserve/controller/AuthControllerTest.java @@ -0,0 +1,72 @@ +package com.pachedev.restoreserve.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestConstructor; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:apitestdb") +@AutoConfigureMockMvc +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +@RequiredArgsConstructor +class AuthControllerTest { + + private final MockMvc mockMvc; + + @Test + void loginReturnsTokenForValidCredentials() throws Exception { + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username": "armymoves", "password": "1234"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").isNotEmpty()); + } + + @Test + void loginWithWrongPasswordReturns401() throws Exception { + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username": "armymoves", "password": "wrong-password"} + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("UNAUTHORIZED")); + } + + @Test + void loginWithUnknownUserReturns401() throws Exception { + mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username": "no-such-user", "password": "whatever"} + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("UNAUTHORIZED")); + } + + @Test + void registerDuplicateUsernameReturns409() throws Exception { + mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Duplicate Check", + "username": "armymoves", + "telephone": "6000000000", + "email": "duplicate-check@example.com", + "password": "password1234" + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.errorCode").value("BUSINESS_LOGIC_ERROR")); + } +} \ No newline at end of file diff --git a/src/test/java/com/pachedev/restoreserve/controller/ReservationControllerTest.java b/src/test/java/com/pachedev/restoreserve/controller/ReservationControllerTest.java new file mode 100644 index 0000000..359b54d --- /dev/null +++ b/src/test/java/com/pachedev/restoreserve/controller/ReservationControllerTest.java @@ -0,0 +1,143 @@ +package com.pachedev.restoreserve.controller; + +import static org.hamcrest.Matchers.everyItem; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestConstructor; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import com.jayway.jsonpath.JsonPath; + +@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:reservationsdb") +@AutoConfigureMockMvc +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +@RequiredArgsConstructor +class ReservationControllerTest { + + private final MockMvc mockMvc; + + /** + * Inicia sesión con las credenciales indicadas y obtiene el JWT de la respuesta. + * + * @param username nombre de usuario + * @param password contraseña del usuario + * @return JWT obtenido del endpoint de login + * @throws Exception si la petición de login falla + */ + private String loginAs(String username, String password) throws Exception { + MvcResult result = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username": "%s", "password": "%s"} + """.formatted(username, password))) + .andExpect(status().isOk()) + .andReturn(); + + return JsonPath.read(result.getResponse().getContentAsString(), "$.token"); + } + + @Test + void reservationWithoutTokenReturns401() throws Exception { + mockMvc.perform(get("/api/v1/reservations")) + .andExpect(status().isUnauthorized()); + } + + @Test + void userSeesOnlyOwnReservations() throws Exception { + String token = loginAs("armymoves", "1234"); + + mockMvc.perform(get("/api/v1/reservations") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].customerName", everyItem(is("Armiche Santana")))); + } + + @Test + void userCannotReadOthersReservationReturns409() throws Exception { + String token = loginAs("armymoves", "1234"); + + mockMvc.perform(get("/api/v1/reservations/2") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.errorCode").value("BUSINESS_LOGIC_ERROR")); + } + + @Test + void reservationExceedingTableCapacityReturns409() throws Exception { + String token = loginAs("armymoves", "1234"); + + mockMvc.perform(post("/api/v1/reservations") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "tableId": 1, + "reservationDate": "2030-03-15T21:00:00", + "numberOfGuests": 6, + "isVip": false + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.errorCode").value("BUSINESS_LOGIC_ERROR")); + } + + @Test + void nonVipUserCannotReserveVipTableReturns403() throws Exception { + String token = loginAs("armymoves", "1234"); + + mockMvc.perform(post("/api/v1/reservations") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "tableId": 1, + "reservationDate": "2030-04-20T19:00:00", + "numberOfGuests": 2, + "isVip": true + } + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value("NOT_VIP_USER")); + } + + @Test + void pastReservationDateReturns400() throws Exception { + String token = loginAs("armymoves", "1234"); + + mockMvc.perform(post("/api/v1/reservations") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "tableId": 1, + "reservationDate": "2020-01-01T20:00:00", + "numberOfGuests": 2, + "isVip": false + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.reservationDate").value("La reserva debe ser en una fecha futura")); + } + + @Test + void emptyReservationBodyReturns400() throws Exception { + String token = loginAs("armymoves", "1234"); + + mockMvc.perform(post("/api/v1/reservations") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.tableId").value("el id de la mesa es obligatorio")); + } +} \ No newline at end of file diff --git a/src/test/java/com/pachedev/restoreserve/controller/RestaurantTableControllerTest.java b/src/test/java/com/pachedev/restoreserve/controller/RestaurantTableControllerTest.java new file mode 100644 index 0000000..7060be8 --- /dev/null +++ b/src/test/java/com/pachedev/restoreserve/controller/RestaurantTableControllerTest.java @@ -0,0 +1,81 @@ +package com.pachedev.restoreserve.controller; + +import static org.hamcrest.Matchers.hasItem; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestConstructor; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import com.jayway.jsonpath.JsonPath; + +@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:tablesdb") +@AutoConfigureMockMvc +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +@RequiredArgsConstructor +class RestaurantTableControllerTest { + + private final MockMvc mockMvc; + + /** + * Inicia sesión con las credenciales indicadas y obtiene el JWT de la respuesta. + * + * @param username nombre de usuario + * @param password contraseña del usuario + * @return JWT obtenido del endpoint de login + * @throws Exception si la petición de login falla + */ + private String loginAs(String username, String password) throws Exception { + MvcResult result = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username": "%s", "password": "%s"} + """.formatted(username, password))) + .andExpect(status().isOk()) + .andReturn(); + + return JsonPath.read(result.getResponse().getContentAsString(), "$.token"); + } + + @Test + void listTablesAsUserReturns403() throws Exception { + String token = loginAs("armymoves", "1234"); + + mockMvc.perform(get("/api/v1/tables").header("Authorization", "Bearer " + token)) + .andExpect(status().isForbidden()); + } + + @Test + void listTablesAsAdminReturnsAllSeededTables() throws Exception { + String token = loginAs("admin", "Admin1234"); + + mockMvc.perform(get("/api/v1/tables").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(4)) + .andExpect(jsonPath("$[*].name", hasItem("Mesa 1"))); + } + + @Test + void createTableAsUser() throws Exception { + String token = loginAs("armymoves", "1234"); + + mockMvc.perform(post("/api/v1/tables").header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Mesa Nueva", + "maxPax": 4, + "location": "SALON" + } + """)) + .andExpect(status().isForbidden()); + } +}