Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand All @@ -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.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

Expand Down Expand Up @@ -66,4 +67,15 @@ public ResponseEntity<ErrorResponse> 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<ErrorResponse> handleAuthentication(AuthenticationException ex) {
ErrorResponse error = new ErrorResponse("UNAUTHORIZED", ex.getMessage(), LocalDateTime.now());

return new ResponseEntity<>(error, HttpStatus.UNAUTHORIZED);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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).
Expand All @@ -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:
*
* <p>
* 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
Expand All @@ -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
Expand All @@ -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();
}
Expand Down
12 changes: 5 additions & 7 deletions src/main/java/com/pachedev/restoreserve/service/AuthService.java
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Loading
Loading