From 6637564c49409c0ee60d6eebfd4a5be9eaaee136 Mon Sep 17 00:00:00 2001 From: DannielEU Date: Fri, 7 Aug 2026 00:08:17 -0500 Subject: [PATCH 1/3] feat: Implement JWT authentication and authorization - Added AuthController for handling login and JWT issuance. - Created JwtAuthFilter to validate JWT tokens in requests. - Developed JwtService for generating and validating JWTs. - Introduced LoginRequest and LoginResponse classes for request and response handling. - Configured Spring Security to manage authentication and authorization. - Implemented caching mechanism for recipe retrieval and counting. - Added observability features including request logging and health checks for the recipe database. - Created tests for JWT service, request logging filter, health indicator, and recipe service caching. --- .env.example | 9 + .github/workflows/nivel-10-autenticacion.yml | 32 +++ .github/workflows/nivel-11-caching.yml | 32 +++ .github/workflows/nivel-12-observabilidad.yml | 42 ++++ docs/curso/06-seguridad/README.md | 10 +- docs/curso/09-proyecto-final/README.md | 27 ++- docs/curso/10-autenticacion/README.md | 117 ++++++++++ docs/curso/11-caching/README.md | 74 +++++++ docs/curso/12-observabilidad/README.md | 94 ++++++++ docs/curso/README.md | 10 + frontend/app.js | 33 +++ pom.xml | 93 +++++++- .../auth/AuthController.java | 80 +++++++ .../auth/JwtAuthFilter.java | 66 ++++++ .../auth/JwtService.java | 81 +++++++ .../auth/LoginRequest.java | 15 ++ .../auth/LoginResponse.java | 19 ++ .../config/CacheConfig.java | 37 ++++ .../config/SecurityConfig.java | 68 ++++++ .../config/WebConfig.java | 25 +++ .../filter/RequestLoggingFilter.java | 85 ++++++++ .../health/RecipeDatabaseHealthIndicator.java | 51 +++++ .../service/RecipeService.java | 47 +++- src/main/resources/application.properties | 16 ++ .../auth/JwtServiceTest.java | 99 +++++++++ .../filter/RequestLoggingFilterTest.java | 74 +++++++ .../RecipeDatabaseHealthIndicatorTest.java | 57 +++++ .../service/RecipeServiceCachingTest.java | 203 ++++++++++++++++++ .../service/RecipeServiceExtraTest.java | 8 +- 29 files changed, 1585 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/nivel-10-autenticacion.yml create mode 100644 .github/workflows/nivel-11-caching.yml create mode 100644 .github/workflows/nivel-12-observabilidad.yml create mode 100644 docs/curso/10-autenticacion/README.md create mode 100644 docs/curso/11-caching/README.md create mode 100644 docs/curso/12-observabilidad/README.md create mode 100644 src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/AuthController.java create mode 100644 src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtAuthFilter.java create mode 100644 src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtService.java create mode 100644 src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginRequest.java create mode 100644 src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginResponse.java create mode 100644 src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/CacheConfig.java create mode 100644 src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/SecurityConfig.java create mode 100644 src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilter.java create mode 100644 src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicator.java create mode 100644 src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtServiceTest.java create mode 100644 src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilterTest.java create mode 100644 src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java create mode 100644 src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceCachingTest.java diff --git a/.env.example b/.env.example index a93c80f..22bb29c 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,15 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000 RATE_LIMIT_MAX_REQUESTS=60 RATE_LIMIT_WINDOW_SECONDS=60 +# --- Autenticacion JWT (nivel 10) --- +# Usuario "demo" para probar /api/auth/login, y la clave con la que se +# firman los tokens. Cambia JWT_SECRET si vas a exponer esto mas alla de tu +# maquina local. +AUTH_DEMO_USERNAME=admin +AUTH_DEMO_PASSWORD=changeme123 +JWT_SECRET=cambia-esta-clave-en-produccion-es-solo-para-desarrollo-local-1234 +JWT_EXPIRATION_MINUTES=30 + # --- Frontend (Docker Compose) --- # URL donde el frontend estático espera encontrar la API. API_BASE_URL=http://localhost:8080 diff --git a/.github/workflows/nivel-10-autenticacion.yml b/.github/workflows/nivel-10-autenticacion.yml new file mode 100644 index 0000000..0e3ede5 --- /dev/null +++ b/.github/workflows/nivel-10-autenticacion.yml @@ -0,0 +1,32 @@ +name: "Nivel 10 - Autenticacion con JWT" + +on: + push: + pull_request: + +jobs: + test-nivel10: + runs-on: ubuntu-latest + steps: + - name: Checkout codigo + uses: actions/checkout@v4 + + - name: Configurar Java 17 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Cache Maven + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Dar permisos de ejecucion a mvnw + run: chmod +x mvnw + + - name: Ejecutar la suite completa (nivel 1-10) + run: ./mvnw -q test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10 diff --git a/.github/workflows/nivel-11-caching.yml b/.github/workflows/nivel-11-caching.yml new file mode 100644 index 0000000..62553d9 --- /dev/null +++ b/.github/workflows/nivel-11-caching.yml @@ -0,0 +1,32 @@ +name: "Nivel 11 - Caching" + +on: + push: + pull_request: + +jobs: + test-nivel11: + runs-on: ubuntu-latest + steps: + - name: Checkout codigo + uses: actions/checkout@v4 + + - name: Configurar Java 17 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Cache Maven + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Dar permisos de ejecucion a mvnw + run: chmod +x mvnw + + - name: Ejecutar la suite completa (nivel 1-11) + run: ./mvnw -q test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11 diff --git a/.github/workflows/nivel-12-observabilidad.yml b/.github/workflows/nivel-12-observabilidad.yml new file mode 100644 index 0000000..3d1d8b8 --- /dev/null +++ b/.github/workflows/nivel-12-observabilidad.yml @@ -0,0 +1,42 @@ +name: "Nivel 12 - Observabilidad" + +on: + push: + pull_request: + +jobs: + test-nivel12: + runs-on: ubuntu-latest + steps: + - name: Checkout codigo + uses: actions/checkout@v4 + + - name: Configurar Java 17 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Cache Maven + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Dar permisos de ejecucion a mvnw + run: chmod +x mvnw + + - name: Ejecutar la suite completa (nivel 1-12) + run: ./mvnw -q test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11,nivel12 + + - name: Verificar cobertura minima del proyecto (JaCoCo) + run: ./mvnw -q verify -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11,nivel12 -Pcoverage-nivel12 + + - name: Publicar reporte de cobertura + if: always() + uses: actions/upload-artifact@v4 + with: + name: jacoco-report-nivel12 + path: target/site/jacoco/ diff --git a/docs/curso/06-seguridad/README.md b/docs/curso/06-seguridad/README.md index 1dfa26a..dbdc9ab 100644 --- a/docs/curso/06-seguridad/README.md +++ b/docs/curso/06-seguridad/README.md @@ -1,10 +1,10 @@ # Nivel 6 — Seguridad básica -No vas a implementar autenticación/autorización completa en este curso (eso -da para todo un curso aparte, con su propio dolor de cabeza dedicado), pero -sí las prácticas de seguridad más básicas que **cualquier** API debería -tener desde el primer día — el equivalente a cerrar la puerta con llave -antes de preocuparte por instalar cámaras. +Todavía no vas a implementar autenticación/autorización (eso llega más +adelante, en el nivel 10 bonus, una vez tengas encima las prácticas más +básicas), pero sí las que **cualquier** API debería tener desde el primer +día — el equivalente a cerrar la puerta con llave antes de preocuparte por +instalar cámaras. ## Parte A — Validación de entrada (Bean Validation) diff --git a/docs/curso/09-proyecto-final/README.md b/docs/curso/09-proyecto-final/README.md index 462f673..7ca6e32 100644 --- a/docs/curso/09-proyecto-final/README.md +++ b/docs/curso/09-proyecto-final/README.md @@ -1,7 +1,7 @@ # Nivel 9 — Proyecto final: Comentarios de receta -Llegaste al último nivel. 🎉 Respira, esto ya es cuesta abajo — no porque -sea fácil, sino porque ya sabes exactamente cómo se ve el camino: lo +Llegaste al cierre del curso base. 🎉 Respira, esto ya es cuesta abajo — no +porque sea fácil, sino porque ya sabes exactamente cómo se ve el camino: lo recorriste ocho veces con `Recipe`. Aquí no hay un método puntual con un `TODO` esperando una línea: vas a construir un recurso **completo**, de punta a punta, replicando por tu cuenta todo lo que aprendiste con `Recipe` @@ -96,8 +96,21 @@ una cobertura mínima del 60% en todo el proyecto y 70% específicamente en `CommentService` (perfil Maven `coverage-nivel9`). Cuando este workflow (`.github/workflows/nivel-09-proyecto-final.yml`) esté -en verde, completaste el curso — ya construiste, de principio a fin, una -API REST siguiendo buenas prácticas de arquitectura, pruebas, seguridad, -paginación y control de tráfico. 🎉 En serio, guarda este repo: dentro de -un año, cuando alguien te pregunte "¿pero tú sí sabes hacer un backend de -verdad?", este es tu recibo. +en verde, completaste el curso base — ya construiste, de principio a fin, +una API REST siguiendo buenas prácticas de arquitectura, pruebas, +seguridad, paginación y control de tráfico. 🎉 En serio, guarda este repo: +dentro de un año, cuando alguien te pregunte "¿pero tú sí sabes hacer un +backend de verdad?", este es tu recibo. + +## ¿Y ahora qué? + +Si quieres seguir exprimiendo este proyecto, hay tres niveles bonus que +llevan la API un poco más allá de lo que la mayoría de cursos cubre: + +- [Nivel 10 — Autenticación con JWT](../10-autenticacion/README.md) +- [Nivel 11 — Caching](../11-caching/README.md) +- [Nivel 12 — Observabilidad](../12-observabilidad/README.md) + +No son obligatorios para decir que "terminaste el curso" — son el +equivalente a las misiones secundarias de un juego: opcionales, pero ahí es +donde vive buena parte de lo que se te va a pedir en un trabajo real. diff --git a/docs/curso/10-autenticacion/README.md b/docs/curso/10-autenticacion/README.md new file mode 100644 index 0000000..94b248a --- /dev/null +++ b/docs/curso/10-autenticacion/README.md @@ -0,0 +1,117 @@ +# Nivel 10 (bonus) — Autenticación con JWT + +Este es el primero de tres niveles **bonus**: no son obligatorios para +"terminar el curso" (eso ya lo lograste en el nivel 9), pero sí son temas +que casi cualquier API real termina necesitando tarde o temprano. Aquí +vamos a cerrar la deuda pendiente del nivel 6: hasta ahora, cualquiera con +la URL puede crear, editar o borrar recetas y comentarios sin que nadie le +pregunte quién es. + +## El problema + +`POST /api/recipes`, `PUT /api/recipes/{name}`, `DELETE /api/recipes/{id}` +(y sus equivalentes en comentarios) están abiertos al mundo. Eso está bien +para aprender CRUD, pero ninguna API real se queda así: alguien tiene que +poder decir "esta petición viene de alguien que ya demostró quién es". + +## JWT en dos frases + +Un **JWT** (JSON Web Token) es un token firmado digitalmente que un +servidor emite después de verificar tus credenciales. El cliente lo guarda +y lo reenvía en cada petición futura (header `Authorization: Bearer +{token}`); el servidor solo tiene que verificar la firma para confiar en el +contenido, sin tener que ir a preguntarle a una base de datos "¿esta sesión +sigue viva?" en cada petición — por eso se dice que es "stateless": toda la +información que el servidor necesita ya viaja dentro del propio token. + +Como el token está firmado (no cifrado), cualquiera puede leer su +contenido decodificando Base64 — pero solo quien conoce la clave secreta +pudo haberlo firmado. Por eso nunca metas datos sensibles dentro de un JWT, +y por eso la clave de firma (`jwt.secret`) nunca debe filtrarse. + +## Cómo está armado este nivel + +- [`LoginRequest.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginRequest.java) / + [`LoginResponse.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginResponse.java) + — los DTOs de entrada/salida de `POST /api/auth/login`. +- [`AuthController.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/AuthController.java) + — ya completo. Compara las credenciales recibidas contra un único usuario + "demo" configurado por variable de entorno (`AUTH_DEMO_USERNAME` / + `AUTH_DEMO_PASSWORD`, ver `application.properties`) y, si coinciden, pide + un token a `JwtService`. En una API real esto sería una tabla de usuarios + con contraseñas hasheadas — simplificamos a un solo usuario para + enfocarnos en el mecanismo de JWT en sí, que es idéntico en ambos casos. +- [`JwtAuthFilter.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtAuthFilter.java) + — ya completo. Lee el header `Authorization`, valida el token con + `JwtService` y, si es válido, marca la petición como autenticada. +- [`SecurityConfig.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/SecurityConfig.java) + — ya completo. Define qué rutas son públicas (login, todos los `GET` de + recetas/comentarios, Swagger, Actuator) y cuáles exigen un JWT válido + (todo lo demás: los `POST`/`PUT`/`DELETE`). + +## Tu tarea: `JwtService.java` + +Todo el mecanismo criptográfico vive, aislado, en +[`JwtService.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtService.java). +Dos métodos: + +- **`generateToken(String username)`**: construye un JWT firmado (HS256) + con `username` como *subject*, fecha de emisión = ahora, y expiración = + ahora + `jwt.expiration-minutes`. La librería es + [jjwt](https://github.com/jwtk/jjwt) (`io.jsonwebtoken`), ya agregada al + `pom.xml`: + + ```java + Jwts.builder() + .setSubject(username) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + expirationMillis)) + .signWith(signingKey, SignatureAlgorithm.HS256) + .compact(); + ``` + +- **`validateAndGetUsername(String token)`**: parsea y valida la firma y la + expiración del token, devolviendo el username si es válido o `null` si + no (corrupto, mal firmado, o expirado — `Jwts.parserBuilder()` lanza + `JwtException` en cualquiera de esos casos; captúrala y devuelve `null`). + +No necesitas tocar `AuthController`, `JwtAuthFilter` ni `SecurityConfig`: +una vez que `JwtService` funciona, todo el resto del mecanismo ya está +conectado. + +## Probarlo a mano + +```bash +# Sin token: las rutas GET siguen abiertas +curl http://localhost:8080/api/recipes + +# Login con el usuario demo (ver .env / application.properties) +curl -X POST http://localhost:8080/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"changeme123"}' +# -> { "token": "eyJhbGciOi..." } + +# Sin token, una escritura debe rechazarse +curl -i -X DELETE http://localhost:8080/api/recipes/algun-id +# -> 401/403 + +# Con token, la misma escritura debe funcionar (si ya implementaste +# createRecipe/deleteRecipe en niveles anteriores) +curl -i -X DELETE http://localhost:8080/api/recipes/algun-id \ + -H "Authorization: Bearer eyJhbGciOi..." +``` + +## Verificación de este nivel + +```bash +./mvnw test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10 +``` + +El corrector es +[`JwtServiceTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtServiceTest.java) +y prueba `JwtService` directamente (sin levantar un servidor HTTP ni Spring +Security completo, igual que `RateLimitFilterTest` en el nivel 8): genera +tokens, los valida, y verifica que un token corrupto, mal firmado, o +expirado devuelva `null`. + +Sigue con el [nivel 11 (caching)](../11-caching/README.md). diff --git a/docs/curso/11-caching/README.md b/docs/curso/11-caching/README.md new file mode 100644 index 0000000..209b0ba --- /dev/null +++ b/docs/curso/11-caching/README.md @@ -0,0 +1,74 @@ +# Nivel 11 (bonus) — Caching + +Cada `GET /api/recipes/{id}` que llega hoy va directo a Mongo, aunque sea +la misma receta que veinte usuarios distintos pidieron en el último minuto. +Eso funciona, pero desperdicia trabajo: si los datos no cambiaron, ¿por qué +volver a preguntarle a la base de datos la misma cosa una y otra vez? + +## Caching en una frase + +Un **caché** guarda el resultado de una operación costosa (aquí, una +consulta a Mongo) para devolverlo directo la próxima vez que alguien pida +exactamente lo mismo, sin repetir el trabajo — como un mesero que ya se +aprendió tu pedido habitual y no necesita ir a preguntarle a cocina otra +vez. El truco (y el riesgo) está en **cuándo invalidar** esa memoria: si +actualizas o borras el dato original y el caché no se entera, empiezas a +servir información vieja. + +## Spring Cache + +Spring trae un mecanismo de caching declarativo: anotas un método con +`@Cacheable` y, antes de ejecutarlo de verdad, Spring revisa si ya tiene +guardado un resultado para esos mismos argumentos — si sí, lo devuelve sin +tocar tu código. `@CacheEvict` hace lo contrario: vacía el caché cuando los +datos ya no son válidos (típicamente, en cualquier operación de escritura). + +[`CacheConfig.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/CacheConfig.java) +ya está completo: activa `@EnableCaching` y define dos cachés en memoria +(`recipeById`, `recipeCount`) usando `ConcurrentMapCacheManager` — la +opción más simple para aprender el mecanismo (en producción normalmente se +usa algo compartido entre instancias, como Redis, pero la forma de usar +`@Cacheable`/`@CacheEvict` en tu código es exactamente la misma). + +## Tu tarea + +Todo tu trabajo en este nivel es agregar anotaciones en +[`RecipeService.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java) +— no necesitas cambiar ningún cuerpo de método, cada uno ya trae un +comentario `TODO (nivel 11)` con la anotación exacta que le corresponde +(y la [`Cacheable`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/Cacheable.html)/[`CacheEvict`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/CacheEvict.html) +que debes importar): + +| Método | Anotación | Por qué | +|---|---|---| +| `countRecipes()` | `@Cacheable(CacheConfig.RECIPE_COUNT_CACHE)` | Lectura pura, se puede cachear sin más. | +| `getRecipeById(id)` | `@Cacheable(value = ..., key = "#id")` | Lectura por clave — el `key` distingue una receta de otra. | +| `saveRecipe(recipe)` | `@CacheEvict(..., allEntries = true)` | Una receta nueva puede cambiar el conteo y (si reemplaza un id) el resultado cacheado de esa receta. | +| `deleteRecipe(id)` | `@CacheEvict(..., allEntries = true)` | Mismo motivo que `saveRecipe`. | +| `updateRecipe(name, recipe)` | `@CacheEvict(value = RECIPE_BY_ID_CACHE, allEntries = true)` | El conteo no cambia con un update, solo hay que invalidar la caché de recetas por id. | + +Sobre el `key = "#id"` de `getRecipeById`: sin él, Spring cachearía **un +solo** resultado para cualquier id (el primero que le pidas) — justo el bug +que no quieres. `"#id"` es [SpEL](https://docs.spring.io/spring-framework/reference/core/expressions.html) +(Spring Expression Language) y hace referencia al parámetro `id` del propio +método. + +## Verificación de este nivel + +```bash +./mvnw test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11 +``` + +El corrector es +[`RecipeServiceCachingTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceCachingTest.java). +A diferencia de los demás correctores de este curso, este sí levanta un +mini contexto de Spring (sin base de datos real, sin servidor HTTP) porque +el caching funciona mediante un proxy que envuelve tu bean — sin ese proxy +no hay forma de observar el efecto de las anotaciones. La estrategia es +siempre la misma: llamar dos veces al mismo método con los mismos +argumentos y verificar, con un repositorio mockeado, cuántas veces la +llamada llegó realmente hasta ahí. Si tu caché funciona, la segunda llamada +nunca debería tocar el repositorio — y si guardas/actualizas/borras algo, +la siguiente lectura sí debería volver a consultarlo. + +Sigue con el [nivel 12 (observabilidad)](../12-observabilidad/README.md). diff --git a/docs/curso/12-observabilidad/README.md b/docs/curso/12-observabilidad/README.md new file mode 100644 index 0000000..30f90cc --- /dev/null +++ b/docs/curso/12-observabilidad/README.md @@ -0,0 +1,94 @@ +# Nivel 12 (bonus) — Observabilidad + +El último nivel del curso. Hasta ahora, si algo falla en producción, tu +única fuente de información son los logs por defecto de Spring Boot y la +suerte. **Observabilidad** es la práctica de instrumentar tu aplicación +para que, cuando algo salga mal a las 3am, puedas responder rápido dos +preguntas: "¿está viva mi aplicación?" y "¿qué pasó exactamente en *esta* +petición?". + +## Parte A — Health check personalizado + +Spring Boot Actuator (ya agregado al `pom.xml`) expone `/actuator/health` +con el estado genérico de la aplicación. Pero "la aplicación arrancó" y "la +aplicación puede realmente hablar con Mongo" son cosas distintas — un +`HealthIndicator` personalizado le agrega a ese endpoint una pieza de +información específica de tu dominio. + +Completa +[`RecipeDatabaseHealthIndicator.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicator.java), +específicamente `doHealthCheck(Health.Builder builder)`: + +- Llama a `recipeRepository.count()`. +- Si no lanza excepción, arma la respuesta con + `builder.up().withDetail("recipes", count)`. +- No necesitas try/catch propio: la clase extiende `AbstractHealthIndicator` + (en vez de implementar `HealthIndicator` directo), que ya envuelve tu + código en un try/catch — si `count()` lanza (Mongo caído, por ejemplo), + Actuator lo convierte automáticamente en `Health.down(esaExcepcion)` en + vez de tumbar el endpoint completo con un 500. + +Cualquier bean que implemente `HealthIndicator` se registra +automáticamente en `/actuator/health` — no necesitas conectarlo a mano en +ningún lado. + +## Parte B — Request id y logging estructurado + +Cuando algo falla, la pregunta casi nunca es "¿qué pasó en el servidor?" +sino "¿qué pasó con **esta** petición específica?". Sin un identificador +que amarre todas las líneas de log de una misma petición, reconstruir esa +historia es como intentar seguir una conversación leyendo los mensajes de +50 chats mezclados en un solo hilo. + +Completa +[`RequestLoggingFilter.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilter.java), +específicamente `resolveRequestId(HttpServletRequest request)`: + +- Si el cliente ya mandó un header `X-Request-Id` (no nulo, no vacío), + reutilízalo tal cual — típico cuando varios servicios se llaman entre sí + y quieres rastrear una petición de punta a punta. +- Si no, genera uno nuevo con `UUID.randomUUID().toString()`. + +El resto del filtro ya está completo: mete el id en el +[MDC](https://logback.qos.ch/manual/mdc.html) de SLF4J (así aparece +automáticamente en cada línea de log mientras dura la petición, sin que +tengas que pasarlo a mano de método en método), lo devuelve en la respuesta +(header `X-Request-Id`, útil para que el cliente lo reporte si algo sale +mal), y registra una línea de log al entrar y otra al salir con el método, +la ruta, el status y la duración. + +## Probarlo a mano + +```bash +# Health check (arranca la app con Mongo corriendo) +curl http://localhost:8080/actuator/health + +# Fíjate en el header de respuesta X-Request-Id +curl -i http://localhost:8080/api/recipes | grep -i x-request-id + +# Y en la consola del servidor, deberías ver un par de líneas "-->"/"<--" +# por cada petición. +``` + +## Verificación de este nivel + +```bash +./mvnw test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11,nivel12 +``` + +Los correctores son +[`RecipeDatabaseHealthIndicatorTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java) +(prueba `health()` directamente con un repositorio mockeado: `UP` cuando +responde, `DOWN` cuando lanza una excepción) y +[`RequestLoggingFilterTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilterTest.java) +(prueba `resolveRequestId(...)` directamente, sin servidor HTTP real, con +un `MockHttpServletRequest`). Este nivel también exige la cobertura mínima +final del proyecto (perfil Maven `coverage-nivel12`, mismas reglas que el +nivel 9: 60% del proyecto, 70% en `CommentService`). + +Cuando este workflow (`.github/workflows/nivel-12-observabilidad.yml`) esté +en verde, terminaste los tres niveles bonus: tu API ahora sabe quién puede +tocarla (nivel 10), no repite trabajo de más (nivel 11), y puede contarte +qué le pasó a cualquier petición individual cuando algo sale mal (nivel +12). Eso es, en la práctica, buena parte de lo que separa un proyecto de +curso de un backend que alguien pondría en producción. 🎉 diff --git a/docs/curso/README.md b/docs/curso/README.md index f508e97..bc7d1a1 100644 --- a/docs/curso/README.md +++ b/docs/curso/README.md @@ -25,6 +25,16 @@ diciéndote "todavía no, pero vas bien". | 8 | [Rate limiting](08-rate-limiting/README.md) | Filtros HTTP, ventanas de tiempo | 🔴 Alta | | 9 | [Proyecto final](09-proyecto-final/README.md) | Un recurso completo desde cero | 🔴 Alta | +Con el nivel 9 completaste el curso base. Los siguientes tres son +**bonus**: opcionales, pero cubren temas que casi cualquier API real +termina necesitando. + +| # | Nivel | De qué se trata | Dificultad | +|---|---|---|---| +| 10 | [Autenticación con JWT](10-autenticacion/README.md) | Spring Security, tokens firmados | 🔴 Alta (bonus) | +| 11 | [Caching](11-caching/README.md) | `@Cacheable`/`@CacheEvict` | 🟠 Media-alta (bonus) | +| 12 | [Observabilidad](12-observabilidad/README.md) | Health checks, request id, logging | 🟠 Media-alta (bonus) | + ## Cómo se evalúa cada nivel Cada nivel tiene su propio workflow en `.github/workflows/nivel-0N-*.yml`. Se diff --git a/frontend/app.js b/frontend/app.js index 5bc75bf..8bc164e 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -72,6 +72,24 @@ const LEVELS = [ description: "GET /api/comments/recipe/{id}", check: () => pingGet("/api/comments/recipe/panel-de-avance"), }, + { + badge: "Nivel 10 (bonus)", + title: "Autenticación con JWT", + description: "POST /api/auth/login con credenciales invalidas debe responder 401.", + check: () => checkAuthLogin(), + }, + { + badge: "Nivel 11 (bonus)", + title: "Caching de lecturas", + description: "Sin endpoint dedicado: revisa el resultado del workflow en Actions.", + check: null, + }, + { + badge: "Nivel 12 (bonus)", + title: "Observabilidad: health check + request id", + description: "GET /actuator/health", + check: () => pingGet("/actuator/health"), + }, ]; async function pingGet(path) { @@ -86,6 +104,21 @@ async function pingGet(path) { } } +async function checkAuthLogin() { + try { + const res = await fetch(API_BASE_URL + "/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: "no-existe", password: "no-existe" }), + }); + // 500 = normalmente UnsupportedOperationException -> el endpoint aún es un TODO. + if (res.status >= 500) return "pending"; + return res.status === 401 ? "ok" : "pending"; + } catch (err) { + return "pending"; + } +} + async function burstCheckRateLimit(path) { try { const requests = Array.from({ length: 12 }, () => fetch(API_BASE_URL + path, { method: "GET" })); diff --git a/pom.xml b/pom.xml index 0138f64..0d4bb20 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,42 @@ spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + + + org.springframework.boot + spring-boot-starter-cache + + + + + org.springframework.boot + spring-boot-starter-actuator + + org.projectlombok lombok @@ -120,10 +156,10 @@ @@ -213,6 +249,55 @@ + + + coverage-nivel12 + + + + org.jacoco + jacoco-maven-plugin + 0.8.10 + + + nivel12-check-project + verify + + check + + + + + BUNDLE + + + LINE + COVEREDRATIO + 0.60 + + + + + CLASS + + *.comment.CommentService + + + + LINE + COVEREDRATIO + 0.70 + + + + + + + + + + + diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/AuthController.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/AuthController.java new file mode 100644 index 0000000..81dd0bc --- /dev/null +++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/AuthController.java @@ -0,0 +1,80 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +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; + +/** + * NIVEL 10 - Login. Ya está completo: no necesitas tocar este archivo. + * + * Este único usuario "demo" (configurado por ambiente, ver + * application.properties) es una simplificación deliberada para este curso. + * En una API real esto sería una tabla de usuarios con contraseñas + * hasheadas — el mecanismo de JWT en sí (que es lo que vas a implementar en + * JwtService) es exactamente el mismo en ambos casos. + */ +@RestController +@RequestMapping("/api/auth") +@Tag(name = "Autenticación", description = "Login y emisión de tokens JWT") +public class AuthController { + + private final JwtService jwtService; + private final String demoUsername; + private final String demoPasswordHash; + private final PasswordEncoder passwordEncoder; + + public AuthController( + JwtService jwtService, + PasswordEncoder passwordEncoder, + @Value("${auth.demo-username}") String demoUsername, + @Value("${auth.demo-password}") String demoPassword) { + this.jwtService = jwtService; + this.passwordEncoder = passwordEncoder; + this.demoUsername = demoUsername; + // Hasheamos la contraseña una sola vez, al arrancar, para nunca + // comparar texto plano contra texto plano (aunque venga de una + // variable de entorno de desarrollo). + this.demoPasswordHash = passwordEncoder.encode(demoPassword); + } + + /** + * POST /api/auth/login + * + * Devuelve 200 + un JWT si las credenciales coinciden con el usuario + * demo, o 401 si no. Mientras JwtService#generateToken no esté + * implementado, devuelve 501 (Not Implemented) — igual que + * GlobalExceptionHandler en el nivel 6 antes de completarlo. + */ + @Operation(summary = "Login: intercambia usuario/contraseña por un JWT") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Login exitoso, token emitido"), + @ApiResponse(responseCode = "401", description = "Usuario o contraseña incorrectos") + }) + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request) { + boolean credentialsMatch = request.getUsername() != null + && demoUsername.equals(request.getUsername()) + && request.getPassword() != null + && passwordEncoder.matches(request.getPassword(), demoPasswordHash); + + if (!credentialsMatch) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + try { + String token = jwtService.generateToken(request.getUsername()); + return ResponseEntity.ok(new LoginResponse(token)); + } catch (UnsupportedOperationException notImplementedYet) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + } +} diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtAuthFilter.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtAuthFilter.java new file mode 100644 index 0000000..73a69c7 --- /dev/null +++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtAuthFilter.java @@ -0,0 +1,66 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth; + +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.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +/** + * NIVEL 10 - Filtro de autenticación JWT. Ya está completo: no necesitas + * tocar este archivo, solo entender qué hace. + * + * A propósito NO está anotado con @Component (a diferencia de + * RateLimitFilter en el nivel 8): si lo estuviera, Spring Boot lo + * registraría automáticamente como filtro global ADEMÁS de dentro de la + * cadena de Spring Security (donde SecurityConfig lo agrega explícitamente + * con addFilterBefore), y terminaría ejecutándose dos veces por petición. + * SecurityConfig es quien lo instancia como @Bean. + * + * Por cada petición: si viene un header "Authorization: Bearer {token}" y + * el token es válido, marca al usuario como autenticado para el resto de la + * cadena (SecurityContextHolder). Si no viene token, o es inválido, o el + * nivel aún no está implementado, simplemente deja pasar la petición SIN + * autenticar — es SecurityConfig (con authorizeHttpRequests) quien decide + * después si esa ruta necesitaba autenticación o no (401/403). + */ +public class JwtAuthFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtService jwtService; + + public JwtAuthFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader("Authorization"); + + if (header != null && header.startsWith(BEARER_PREFIX)) { + String token = header.substring(BEARER_PREFIX.length()); + try { + String username = jwtService.validateAndGetUsername(token); + if (username != null) { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (UnsupportedOperationException notImplementedYet) { + // Nivel 10 todavia no implementado: nadie puede autenticarse + // todavia (las rutas protegidas seguiran respondiendo + // 401/403), pero tampoco tumbamos toda la aplicacion por + // esto - mismo espiritu que RateLimitFilter en el nivel 8. + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtService.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtService.java new file mode 100644 index 0000000..cc88c99 --- /dev/null +++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtService.java @@ -0,0 +1,81 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +/** + * NIVEL 10 - Autenticación y autorización con JWT. + * + * Un JWT (JSON Web Token) es un "carnet" firmado digitalmente: contiene + * información (aquí, solo el username) más una firma que prueba que lo + * emitió tu servidor y que nadie lo alteró en el camino. Como está firmado + * (no cifrado), cualquiera puede leer su contenido — pero solo tu servidor, + * que conoce la clave secreta, puede haberlo firmado o verificarlo. Por eso + * nunca metas datos sensibles (contraseñas, tarjetas) dentro del token. + * + * Esta clase concentra TODA la lógica de JWT en dos métodos aislados y + * fáciles de probar sin levantar un servidor (igual que + * RateLimitFilter#isRequestAllowed en el nivel 8). El resto del mecanismo + * (AuthController, JwtAuthFilter, SecurityConfig) ya está completo — tu + * única tarea es esta clase. + */ +@Component +public class JwtService { + + private final SecretKey signingKey; + private final long expirationMillis; + + public JwtService( + @Value("${jwt.secret}") String secret, + @Value("${jwt.expiration-minutes}") long expirationMinutes) { + this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.expirationMillis = expirationMinutes * 60_000L; + } + + /** + * TODO (nivel 10): genera un JWT firmado (HS256) para el usuario dado. + * + * Pistas: + * - Jwts.builder() + * .setSubject(username) + * .setIssuedAt(new Date()) + * .setExpiration(new Date(System.currentTimeMillis() + expirationMillis)) + * .signWith(signingKey, SignatureAlgorithm.HS256) + * .compact(); + * - "compact()" es lo que produce el string final del token + * (algo como "eyJhbGciOiJIUzI1NiJ9...."). + * + * @param username identificador del usuario autenticado (el "subject" del token). + * @return el JWT compacto, listo para devolver al cliente. + */ + public String generateToken(String username) { + throw new UnsupportedOperationException("TODO nivel 10: implementa generateToken"); + } + + /** + * TODO (nivel 10): valida la firma y la expiración de un JWT y devuelve + * el username (subject) si es válido. + * + * Pistas: + * - Jwts.parserBuilder().setSigningKey(signingKey).build() + * .parseClaimsJws(token).getBody() te da el objeto Claims. + * - Claims#getSubject() te da el username. + * - Si el token está corrupto, mal firmado, o expiró, el parser lanza + * una JwtException (ExpiredJwtException incluida, que es subclase + * de JwtException) — captúrala y devuelve null en ese caso, en vez + * de dejar que la excepción se propague. + * + * @param token el JWT recibido en el header Authorization. + * @return el username si el token es válido, o null si es inválido/expiró. + */ + public String validateAndGetUsername(String token) { + throw new UnsupportedOperationException("TODO nivel 10: implementa validateAndGetUsername"); + } +} diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginRequest.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginRequest.java new file mode 100644 index 0000000..7873af4 --- /dev/null +++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginRequest.java @@ -0,0 +1,15 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth; + +/** + * NIVEL 10 - Cuerpo de entrada de POST /api/auth/login. + */ +public class LoginRequest { + private String username; + private String 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; } +} diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginResponse.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginResponse.java new file mode 100644 index 0000000..bcd4fb7 --- /dev/null +++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginResponse.java @@ -0,0 +1,19 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth; + +/** + * NIVEL 10 - Cuerpo de respuesta de POST /api/auth/login: el token que el + * cliente debe reenviar en el header "Authorization: Bearer {token}" en + * cada petición a una ruta protegida. + */ +public class LoginResponse { + private String token; + + public LoginResponse() { } + + public LoginResponse(String token) { + this.token = token; + } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } +} diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/CacheConfig.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/CacheConfig.java new file mode 100644 index 0000000..6487428 --- /dev/null +++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/CacheConfig.java @@ -0,0 +1,37 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.config; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * NIVEL 11 - Caching. Ya está completo: no necesitas tocar este archivo. + * + * @EnableCaching activa el mecanismo de caché de Spring: cuando anotas un + * método con @Cacheable, Spring envuelve el bean en un proxy que, antes de + * ejecutar el método de verdad, revisa si ya tiene el resultado guardado + * para esos mismos argumentos — si sí, lo devuelve directo, sin tocar la + * base de datos (como un mesero que ya se acuerda de memoria tu pedido + * habitual y no necesita ir a preguntarle a cocina otra vez). + * + * ConcurrentMapCacheManager guarda todo en memoria (un Map por caché, + * nombrado). Es la opción más simple para aprender el mecanismo — en + * producción normalmente se usa algo compartido entre instancias (Redis, + * Caffeine con métricas, etc.), pero la forma de usar @Cacheable/@CacheEvict + * en tu código es exactamente la misma sin importar el CacheManager de + * fondo. + */ +@Configuration +@EnableCaching +public class CacheConfig { + + public static final String RECIPE_BY_ID_CACHE = "recipeById"; + public static final String RECIPE_COUNT_CACHE = "recipeCount"; + + @Bean + public CacheManager cacheManager() { + return new ConcurrentMapCacheManager(RECIPE_BY_ID_CACHE, RECIPE_COUNT_CACHE); + } +} diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/SecurityConfig.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/SecurityConfig.java new file mode 100644 index 0000000..aab13b5 --- /dev/null +++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/SecurityConfig.java @@ -0,0 +1,68 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.config; + +import edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth.JwtAuthFilter; +import edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth.JwtService; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +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; + +/** + * NIVEL 10 - Configuración de Spring Security. Ya está completa: no + * necesitas tocar este archivo, solo entender qué hace. + * + * Reglas de autorización de esta API: + * - POST /api/auth/login: público (si no, nadie podría autenticarse). + * - GET /api/recipes/** y /api/comments/**: públicos (leer no requiere + * login, igual que en los niveles 1-9). + * - Swagger UI y Actuator (nivel 12): públicos, para que puedas seguir + * explorando/verificando la API sin token. + * - Cualquier otra ruta (POST/PUT/DELETE de recetas y comentarios): + * requiere un JWT válido en el header Authorization. + * + * La API es "stateless": no usa sesiones ni cookies, cada petición se + * autentica sola con el token que trae (por eso sessionCreationPolicy es + * STATELESS y csrf está deshabilitado - CSRF es un riesgo de sesiones + * basadas en cookies, no de APIs stateless con Bearer tokens). + */ +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public JwtAuthFilter jwtAuthFilter(JwtService jwtService) { + return new JwtAuthFilter(jwtService); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception { + http + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .exceptionHandling(handling -> handling.authenticationEntryPoint( + (request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED))) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll() + .requestMatchers(HttpMethod.GET, "/api/recipes/**", "/api/comments/**").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/actuator/**").permitAll() + .anyRequest().authenticated()) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/WebConfig.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/WebConfig.java index 1385938..2050c47 100644 --- a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/WebConfig.java +++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/WebConfig.java @@ -1,10 +1,16 @@ package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.config; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.Arrays; + /** * NIVEL 1/6 - Configuración de CORS (ya implementada, léela como ejemplo). * @@ -48,4 +54,23 @@ public void addCorsMappings(CorsRegistry registry) { .allowedOrigins(origins) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS"); } + + /** + * NIVEL 10 - Spring Security intercepta las peticiones ANTES que el + * mapeo de CORS de arriba (addCorsMappings), así que necesita su propia + * fuente de configuración de CORS para no bloquear el preflight OPTIONS + * del navegador. Reutiliza los mismos orígenes permitidos por variable + * de entorno; SecurityConfig la referencia con ".cors(...)". + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList(resolveAllowedOrigins())); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", configuration); + return source; + } } diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilter.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilter.java new file mode 100644 index 0000000..ff1ea62 --- /dev/null +++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilter.java @@ -0,0 +1,85 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +/** + * NIVEL 12 - Observabilidad: request id + logging estructurado. + * + * Cuando algo falla en producción, la pregunta casi nunca es "¿qué pasó en + * el servidor?" sino "¿qué pasó con ESTA petición específica de ESTE + * usuario?". Sin un identificador que amarre todas las líneas de log de una + * misma petición, buscar la respuesta es como intentar reconstruir una + * conversación leyendo los mensajes de 50 chats mezclados en un solo hilo. + * + * Este filtro le asigna a cada petición un "request id" (lo reutiliza si el + * cliente ya mandó uno en el header X-Request-Id — típico cuando varios + * servicios se llaman entre sí y quieres rastrear una petición de punta a + * punta; si no, genera uno nuevo), lo mete en el MDC de SLF4J (así aparece + * automáticamente en cada línea de log mientras dura la petición, sin que + * tengas que pasarlo a mano de método en método) y lo devuelve también en + * la respuesta, para que el cliente pueda reportar ese id si algo sale mal. + * + * doFilterInternal ya está completo. Tu única tarea es resolveRequestId(...) + * — el resto (MDC, logging, header de respuesta) no necesitas tocarlo. + */ +@Component +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final String MDC_KEY = "requestId"; + + /** + * TODO (nivel 12): devuelve el request id a usar para esta petición. + * + * Pistas: + * - Si request.getHeader(REQUEST_ID_HEADER) no es null ni está vacío + * (después de un trim()), reutilízalo tal cual. + * - Si no, genera uno nuevo con UUID.randomUUID().toString(). + * + * @param request la petición HTTP entrante. + * @return el request id a usar (nunca null ni vacío). + */ + public String resolveRequestId(HttpServletRequest request) { + throw new UnsupportedOperationException("TODO nivel 12: implementa resolveRequestId"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String requestId; + try { + requestId = resolveRequestId(request); + } catch (UnsupportedOperationException notImplementedYet) { + // Nivel 12 todavia no implementado: no rompas peticiones de + // niveles anteriores por esto, simplemente no hay request id + // todavia (mismo espiritu que RateLimitFilter en el nivel 8). + filterChain.doFilter(request, response); + return; + } + + MDC.put(MDC_KEY, requestId); + response.setHeader(REQUEST_ID_HEADER, requestId); + long start = System.currentTimeMillis(); + try { + log.info("--> {} {}", request.getMethod(), request.getRequestURI()); + filterChain.doFilter(request, response); + } finally { + long durationMillis = System.currentTimeMillis() - start; + log.info("<-- {} {} {} ({} ms)", request.getMethod(), request.getRequestURI(), + response.getStatus(), durationMillis); + MDC.remove(MDC_KEY); + } + } +} diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicator.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicator.java new file mode 100644 index 0000000..c4c468c --- /dev/null +++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicator.java @@ -0,0 +1,51 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.health; + +import edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.repository.RecipeRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.health.AbstractHealthIndicator; +import org.springframework.boot.actuate.health.Health; +import org.springframework.stereotype.Component; + +/** + * NIVEL 12 - Observabilidad: health check personalizado. + * + * Spring Boot Actuator ya expone /actuator/health con el estado genérico de + * la aplicación (¿arrancó? ¿responde?), pero eso no te dice si tu API + * realmente puede hablar con Mongo — una aplicación puede estar "viva" y + * aun así ser incapaz de servir una sola petición real porque la base de + * datos está caída. Un HealthIndicator personalizado le agrega a + * /actuator/health una pieza de información específica de TU dominio, + * exactamente lo que un servicio de monitoreo (o tu equipo de guardia a las + * 3am) necesita para saber si "verde" significa "verde de verdad". + * + * Cualquier bean que implemente HealthIndicator se registra + * automáticamente: no necesitas conectarlo a mano en ningún lado. + * + * Extiende AbstractHealthIndicator (en vez de implementar HealthIndicator + * directo) porque ya trae, gratis, un try/catch alrededor de + * doHealthCheck(...): si tu código lanza cualquier excepción, Actuator la + * convierte automáticamente en Health.down(esaExcepcion) en vez de tumbar + * el endpoint completo con un 500. + */ +@Component +public class RecipeDatabaseHealthIndicator extends AbstractHealthIndicator { + + @Autowired + private RecipeRepository recipeRepository; + + /** + * TODO (nivel 12): implementa el chequeo de salud de la base de datos. + * + * Pista: llama a recipeRepository.count() y, si no lanza excepción, + * arma la respuesta con builder.up().withDetail("recipes", count) — no + * necesitas try/catch propio: si count() lanza (Mongo caído, por + * ejemplo), AbstractHealthIndicator ya se encarga de convertir esa + * excepción en un Health.down(...) por ti. + * + * @param builder constructor del resultado de salud. + */ + @Override + protected void doHealthCheck(Health.Builder builder) throws Exception { + throw new UnsupportedOperationException("TODO nivel 12: implementa doHealthCheck"); + } +} diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java index 30ce4f1..e13eff8 100644 --- a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java +++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java @@ -27,6 +27,13 @@ public class RecipeService { * Cuenta el total de recetas almacenadas. * Spring Data ya trae este conteo gratis en toda MongoRepository — a * veces la mejor línea de código es la que no tienes que escribir. + * + * TODO (nivel 11): agrega @Cacheable(CacheConfig.RECIPE_COUNT_CACHE) + * justo encima de este método (importa + * org.springframework.cache.annotation.Cacheable). No necesitas cambiar + * el cuerpo del método — Spring intercepta la llamada ANTES de que + * entre aquí si ya tiene el resultado en caché. + * * @return número total de recetas. */ public long countRecipes() { @@ -45,6 +52,15 @@ public Page getRecipesPage(int page, int size) { /** * Guarda una receta en la base de datos. + * + * TODO (nivel 11): agrega + * @CacheEvict(value = {CacheConfig.RECIPE_BY_ID_CACHE, CacheConfig.RECIPE_COUNT_CACHE}, allEntries = true) + * encima de este método (importa + * org.springframework.cache.annotation.CacheEvict). Guardar una receta + * nueva puede cambiar el conteo total y, si reemplaza un id existente, + * el resultado cacheado de getRecipeById — la única forma segura de no + * servir datos viejos es vaciar ambas cachés cuando esto pasa. + * * @param recipe Receta a guardar. * @return Receta guardada. */ @@ -62,6 +78,13 @@ public List getAllRecipes() { /** * Busca una receta por su ID. + * + * TODO (nivel 11): agrega @Cacheable(value = CacheConfig.RECIPE_BY_ID_CACHE, key = "#id") + * encima de este método. El "key" es lo que distingue una entrada de + * caché de otra — sin él, Spring cachearía UN solo resultado para + * cualquier id (el primero que le pidas), que es justo el bug que no + * quieres. + * * @param id Identificador de la receta. * @return Optional con la receta encontrada o vacío si no existe. */ @@ -71,6 +94,11 @@ public Optional getRecipeById(String id) { /** * Elimina una receta por su ID. + * + * TODO (nivel 11): agrega + * @CacheEvict(value = {CacheConfig.RECIPE_BY_ID_CACHE, CacheConfig.RECIPE_COUNT_CACHE}, allEntries = true) + * encima de este método (mismo motivo que en saveRecipe). + * * @param id Identificador de la receta a eliminar. */ public void deleteRecipe(String id) { @@ -115,7 +143,18 @@ public List findByChefType(String chefType) { * @return Entidad Recipe. */ public Recipe toEntity(RecipeDTO dto) { - throw new UnsupportedOperationException("TODO nivel 3: implementa toEntity"); + edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.model.Chef chef; + Integer season = dto.getSeason(); + if ("Contestant".equals(dto.getChefType())) { + chef = new Contestant(dto.getChefName(), season == null ? 0 : season); + } else if ("Judge".equals(dto.getChefType())) { + chef = new Judge(dto.getChefName()); + } else { + chef = new Viewer(dto.getChefName()); + } + List ingredients = dto.getIngredients().stream().map(Ingredient::new).collect(Collectors.toList()); + List steps = dto.getSteps().stream().map(Step::new).collect(Collectors.toList()); + return new Recipe(dto.getTitle(), ingredients, steps, chef); } /** @@ -142,6 +181,12 @@ public RecipeDTO toDTO(Recipe recipe) { /** * Busca una receta por su nombre y la actualiza. + * + * TODO (nivel 11): agrega @CacheEvict(value = CacheConfig.RECIPE_BY_ID_CACHE, allEntries = true) + * encima de este método. (El conteo total no cambia con un update, así + * que a diferencia de saveRecipe/deleteRecipe aquí solo hace falta + * vaciar la caché de recetas por id, no la de conteo.) + * * @param name Nombre de la receta a buscar. * @param updatedRecipe Receta actualizada. * @return Receta actualizada o null si no se encontró. diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d2d7bfa..de9fb80 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,3 +17,19 @@ cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000} # curioso, no tanto para un script en bucle infinito. ratelimit.max-requests=${RATE_LIMIT_MAX_REQUESTS:60} ratelimit.window-seconds=${RATE_LIMIT_WINDOW_SECONDS:60} + +# Nivel 10 - Autenticacion: un unico usuario "demo" (en un caso real esto +# seria una tabla de usuarios con contrasenas hasheadas en la base de datos, +# aqui simplificamos a un usuario para enfocarnos en el mecanismo de JWT) y +# la clave con la que se firman los tokens. NUNCA reutilices el valor por +# defecto de jwt.secret en produccion - cambialo por variable de entorno. +auth.demo-username=${AUTH_DEMO_USERNAME:admin} +auth.demo-password=${AUTH_DEMO_PASSWORD:changeme123} +jwt.secret=${JWT_SECRET:cambia-esta-clave-en-produccion-es-solo-para-desarrollo-local-1234} +jwt.expiration-minutes=${JWT_EXPIRATION_MINUTES:30} + +# Nivel 12 - Observabilidad: endpoints de Actuator expuestos publicamente. +# Solo health/info/metrics - nunca expongas "env" o "beans" sin proteger, +# revelan variables de entorno y el arbol de dependencias completo. +management.endpoints.web.exposure.include=health,info,metrics +management.endpoint.health.show-details=always diff --git a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtServiceTest.java b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtServiceTest.java new file mode 100644 index 0000000..10c160c --- /dev/null +++ b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtServiceTest.java @@ -0,0 +1,99 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * NIVEL 10 - Corrector de autenticación JWT (docs/curso/10-autenticacion). + * + * No necesitas modificar este archivo. Prueba JwtService directamente (sin + * levantar un servidor HTTP ni Spring Security), igual que RateLimitFilter + * en el nivel 8. + */ +class JwtServiceTest { + + private static final String SECRET = "clave-de-pruebas-que-tiene-mas-de-32-caracteres-para-hs256"; + + @Test + @Tag("nivel10") + @DisplayName("Debe generar un token no nulo con tres partes (header.payload.firma)") + void testGenerateToken_producesNonNullToken() { + JwtService jwtService = new JwtService(SECRET, 30); + + String token = jwtService.generateToken("ana"); + + assertNotNull(token); + assertNotEquals("", token.trim()); + // Un JWT compacto siempre tiene exactamente 3 partes separadas por ".". + assertEquals(3, token.split("\\.").length); + } + + @Test + @Tag("nivel10") + @DisplayName("Debe generar tokens distintos para usuarios distintos") + void testGenerateToken_differentUsers_produceDifferentTokens() { + JwtService jwtService = new JwtService(SECRET, 30); + + String tokenAna = jwtService.generateToken("ana"); + String tokenLuis = jwtService.generateToken("luis"); + + assertNotEquals(tokenAna, tokenLuis); + } + + @Test + @Tag("nivel10") + @DisplayName("Debe validar un token recien generado y devolver el username original") + void testValidateAndGetUsername_validToken_returnsUsername() { + JwtService jwtService = new JwtService(SECRET, 30); + + String token = jwtService.generateToken("chef123"); + String username = jwtService.validateAndGetUsername(token); + + assertEquals("chef123", username); + } + + @Test + @Tag("nivel10") + @DisplayName("Debe devolver null para un token invalido o corrupto") + void testValidateAndGetUsername_garbageToken_returnsNull() { + JwtService jwtService = new JwtService(SECRET, 30); + + String username = jwtService.validateAndGetUsername("esto-no-es-un-jwt-valido"); + + assertNull(username); + } + + @Test + @Tag("nivel10") + @DisplayName("Debe devolver null para un token firmado con otra clave") + void testValidateAndGetUsername_wrongSignature_returnsNull() { + JwtService issuer = new JwtService(SECRET, 30); + JwtService verifier = new JwtService("otra-clave-completamente-distinta-de-mas-de-32-caracteres", 30); + + String token = issuer.generateToken("ana"); + String username = verifier.validateAndGetUsername(token); + + assertNull(username); + } + + @Test + @Tag("nivel10") + @DisplayName("Debe devolver null para un token expirado") + void testValidateAndGetUsername_expiredToken_returnsNull() throws InterruptedException { + // expiration-minutes = 0: el token nace expirado. + JwtService jwtService = new JwtService(SECRET, 0); + + String token = jwtService.generateToken("ana"); + Thread.sleep(5); // asegura que el reloj avance mas alla de la expiracion + + String username = jwtService.validateAndGetUsername(token); + + assertNull(username); + } +} diff --git a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilterTest.java b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilterTest.java new file mode 100644 index 0000000..65ce711 --- /dev/null +++ b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilterTest.java @@ -0,0 +1,74 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.filter; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * NIVEL 12 - Corrector de request id (docs/curso/12-observabilidad). + * + * No necesitas modificar este archivo. Prueba + * RequestLoggingFilter#resolveRequestId(...) directamente (sin levantar un + * servidor HTTP), con un MockHttpServletRequest de Spring Test. + */ +class RequestLoggingFilterTest { + + private final RequestLoggingFilter filter = new RequestLoggingFilter(); + + @Test + @Tag("nivel12") + @DisplayName("Debe reutilizar el request id si el cliente ya mando uno") + void testResolveRequestId_reusesIncomingHeader() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Request-Id", "id-del-cliente-123"); + + String requestId = filter.resolveRequestId(request); + + assertEquals("id-del-cliente-123", requestId); + } + + @Test + @Tag("nivel12") + @DisplayName("Debe generar un request id cuando el cliente no manda ninguno") + void testResolveRequestId_generatesWhenMissing() { + MockHttpServletRequest request = new MockHttpServletRequest(); + + String requestId = filter.resolveRequestId(request); + + assertNotNull(requestId); + assertFalse(requestId.trim().isEmpty()); + } + + @Test + @Tag("nivel12") + @DisplayName("Debe generar request ids distintos en llamadas distintas sin header") + void testResolveRequestId_generatesUniqueIds() { + MockHttpServletRequest requestA = new MockHttpServletRequest(); + MockHttpServletRequest requestB = new MockHttpServletRequest(); + + String idA = filter.resolveRequestId(requestA); + String idB = filter.resolveRequestId(requestB); + + assertNotEquals(idA, idB); + } + + @Test + @Tag("nivel12") + @DisplayName("Debe ignorar un header X-Request-Id vacio y generar uno nuevo") + void testResolveRequestId_blankHeader_generatesNew() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Request-Id", " "); + + String requestId = filter.resolveRequestId(request); + + assertTrue(requestId != null && !requestId.trim().isEmpty()); + assertNotEquals(" ", requestId); + } +} diff --git a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java new file mode 100644 index 0000000..a8b2d41 --- /dev/null +++ b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java @@ -0,0 +1,57 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.health; + +import edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.repository.RecipeRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +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.actuate.health.Health; +import org.springframework.boot.actuate.health.Status; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +/** + * NIVEL 12 - Corrector del health check personalizado + * (docs/curso/12-observabilidad). No necesitas modificar este archivo. + * + * Llama a health() directamente, sin levantar Actuator ni un servidor HTTP + * real — mismo espíritu de aislamiento que los correctores de los niveles + * 8 y 10. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("Health check de recetas - Nivel 12") +class RecipeDatabaseHealthIndicatorTest { + + @Mock + private RecipeRepository recipeRepository; + + @InjectMocks + private RecipeDatabaseHealthIndicator healthIndicator; + + @Test + @Tag("nivel12") + @DisplayName("Debe reportar UP y el conteo de recetas cuando el repositorio responde") + void testHealth_repositoryResponds_reportsUp() { + when(recipeRepository.count()).thenReturn(7L); + + Health health = healthIndicator.health(); + + assertEquals(Status.UP, health.getStatus()); + assertEquals(7L, health.getDetails().get("recipes")); + } + + @Test + @Tag("nivel12") + @DisplayName("Debe reportar DOWN cuando el repositorio lanza una excepcion") + void testHealth_repositoryFails_reportsDown() { + when(recipeRepository.count()).thenThrow(new RuntimeException("mongo no responde")); + + Health health = healthIndicator.health(); + + assertEquals(Status.DOWN, health.getStatus()); + } +} diff --git a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceCachingTest.java b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceCachingTest.java new file mode 100644 index 0000000..0f9fd43 --- /dev/null +++ b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceCachingTest.java @@ -0,0 +1,203 @@ +package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.service; + +import edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.model.Recipe; +import edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.repository.RecipeRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * NIVEL 11 - Corrector de caching (docs/curso/11-caching). + * + * No necesitas modificar este archivo. A diferencia de los demás + * correctores de este curso, este SÍ levanta un mini contexto de Spring + * (sin base de datos real, sin servidor HTTP) porque el caching de Spring + * funciona mediante un proxy que envuelve el bean — sin ese proxy no hay + * forma de observar el efecto de @Cacheable/@CacheEvict. + * + * La estrategia de cada prueba es siempre la misma: llamar dos veces al + * mismo método de RecipeService con los mismos argumentos y verificar + * cuántas veces llegó realmente hasta RecipeRepository (el mock). Si tu + * caché funciona, la segunda llamada nunca debería tocar el repositorio. + */ +class RecipeServiceCachingTest { + + /** Contexto minimo: solo lo necesario para que @Cacheable/@CacheEvict funcionen. */ + @Configuration + @EnableCaching + static class CachingTestConfig { + @Bean + CacheManager cacheManager() { + return new ConcurrentMapCacheManager("recipeById", "recipeCount"); + } + + @Bean + RecipeRepository recipeRepository() { + return mock(RecipeRepository.class); + } + + @Bean + RecipeService recipeService() { + return new RecipeService(); + } + } + + private AnnotationConfigApplicationContext context; + private RecipeRepository recipeRepository; + private RecipeService recipeService; + + @BeforeEach + void setUp() { + context = new AnnotationConfigApplicationContext(CachingTestConfig.class); + recipeRepository = context.getBean(RecipeRepository.class); + recipeService = context.getBean(RecipeService.class); + } + + @AfterEach + void tearDown() { + context.close(); + } + + @Test + @Tag("nivel11") + @DisplayName("getRecipeById no debe volver a consultar el repositorio para el mismo id") + void testGetRecipeById_secondCallHitsCache() { + Recipe recipe = new Recipe(); + recipe.setId("id1"); + when(recipeRepository.findById("id1")).thenReturn(Optional.of(recipe)); + + recipeService.getRecipeById("id1"); + recipeService.getRecipeById("id1"); + recipeService.getRecipeById("id1"); + + verify(recipeRepository, times(1)).findById("id1"); + } + + @Test + @Tag("nivel11") + @DisplayName("getRecipeById debe cachear cada id por separado") + void testGetRecipeById_isCachedPerId() { + Recipe recipeA = new Recipe(); + recipeA.setId("a"); + Recipe recipeB = new Recipe(); + recipeB.setId("b"); + when(recipeRepository.findById("a")).thenReturn(Optional.of(recipeA)); + when(recipeRepository.findById("b")).thenReturn(Optional.of(recipeB)); + + recipeService.getRecipeById("a"); + recipeService.getRecipeById("b"); + recipeService.getRecipeById("a"); + recipeService.getRecipeById("b"); + + verify(recipeRepository, times(1)).findById("a"); + verify(recipeRepository, times(1)).findById("b"); + } + + @Test + @Tag("nivel11") + @DisplayName("countRecipes no debe volver a consultar el repositorio") + void testCountRecipes_secondCallHitsCache() { + when(recipeRepository.count()).thenReturn(5L); + + recipeService.countRecipes(); + recipeService.countRecipes(); + + verify(recipeRepository, times(1)).count(); + } + + @Test + @Tag("nivel11") + @DisplayName("saveRecipe debe invalidar la cache de getRecipeById") + void testSaveRecipe_evictsRecipeByIdCache() { + Recipe original = new Recipe(); + original.setId("id1"); + when(recipeRepository.findById("id1")).thenReturn(Optional.of(original)); + when(recipeRepository.save(any(Recipe.class))).thenReturn(original); + + recipeService.getRecipeById("id1"); // 1ra consulta real, queda en cache + recipeService.getRecipeById("id1"); // deberia venir de cache + + recipeService.saveRecipe(new Recipe()); // deberia vaciar la cache + + recipeService.getRecipeById("id1"); // deberia consultar de nuevo + + verify(recipeRepository, times(2)).findById("id1"); + } + + @Test + @Tag("nivel11") + @DisplayName("deleteRecipe debe invalidar tanto la cache de getRecipeById como la de countRecipes") + void testDeleteRecipe_evictsBothCaches() { + Recipe original = new Recipe(); + original.setId("id1"); + when(recipeRepository.findById("id1")).thenReturn(Optional.of(original)); + when(recipeRepository.count()).thenReturn(3L); + + recipeService.getRecipeById("id1"); + recipeService.countRecipes(); + + recipeService.deleteRecipe("id1"); + + recipeService.getRecipeById("id1"); + recipeService.countRecipes(); + + verify(recipeRepository, times(2)).findById("id1"); + verify(recipeRepository, times(2)).count(); + } + + @Test + @Tag("nivel11") + @DisplayName("updateRecipe debe invalidar la cache de getRecipeById") + void testUpdateRecipe_evictsRecipeByIdCache() { + Recipe original = new Recipe(); + original.setId("id1"); + original.setTitle("Torta"); + when(recipeRepository.findById("id1")).thenReturn(Optional.of(original)); + when(recipeRepository.findByTitle("Torta")).thenReturn(java.util.List.of(original)); + when(recipeRepository.save(any(Recipe.class))).thenReturn(original); + + recipeService.getRecipeById("id1"); + + Recipe updated = new Recipe(); + updated.setTitle("Torta Actualizada"); + recipeService.updateRecipe("Torta", updated); + + recipeService.getRecipeById("id1"); + + verify(recipeRepository, times(2)).findById("id1"); + } + + @Test + @Tag("nivel11") + @DisplayName("Sanity check: el resultado devuelto sigue siendo correcto, cacheado o no") + void testGetRecipeById_returnsCorrectValueWhileCached() { + Recipe recipe = new Recipe(); + recipe.setId("id1"); + recipe.setTitle("Torta de chocolate"); + when(recipeRepository.findById("id1")).thenReturn(Optional.of(recipe)); + + Optional first = recipeService.getRecipeById("id1"); + Optional second = recipeService.getRecipeById("id1"); + + assertTrue(first.isPresent()); + assertTrue(second.isPresent()); + assertTrue(first.get().getTitle().equals(second.get().getTitle())); + } +} diff --git a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java index dc328e5..e91b305 100644 --- a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java +++ b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java @@ -49,7 +49,9 @@ void setUp() { @Test @Tag("nivel5") void testCountRecipes() { - fail("TODO nivel 5: implementa testCountRecipes"); + org.mockito.Mockito.when(recipeRepository.count()).thenReturn(5L); + long result = recipeService.countRecipes(); + org.junit.jupiter.api.Assertions.assertEquals(5L, result); } /** @@ -59,7 +61,9 @@ void testCountRecipes() { @Test @Tag("nivel5") void testCountRecipes_whenEmpty() { - fail("TODO nivel 5: implementa testCountRecipes_whenEmpty"); + org.mockito.Mockito.when(recipeRepository.count()).thenReturn(0L); + long result = recipeService.countRecipes(); + org.junit.jupiter.api.Assertions.assertEquals(0L, result); } /** From 3799a2c460fac2383f29db5f239413771db6d5e7 Mon Sep 17 00:00:00 2001 From: DannielEU Date: Fri, 7 Aug 2026 00:12:30 -0500 Subject: [PATCH 2/3] refactor: Update unit test instructions and add missing test cases for RecipeService --- docs/curso/05-pruebas-unitarias/README.md | 8 +++--- .../service/RecipeService.java | 13 +--------- .../service/RecipeServiceExtraTest.java | 26 ++++++++----------- 3 files changed, 17 insertions(+), 30 deletions(-) diff --git a/docs/curso/05-pruebas-unitarias/README.md b/docs/curso/05-pruebas-unitarias/README.md index 9bb8936..ad93d93 100644 --- a/docs/curso/05-pruebas-unitarias/README.md +++ b/docs/curso/05-pruebas-unitarias/README.md @@ -47,9 +47,11 @@ void testFindBySeason() { Completa [`RecipeServiceExtraTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java). Tiene 3 métodos con `fail("TODO ...")` — bórralo y escribe una prueba real -para cada uno, probando `countRecipes()` y `getRecipesPage(...)` (los que -implementaste en los niveles 2 y 7) **a nivel de `RecipeService`**, no de -`RecipeController`. +para cada uno, probando `countRecipes()` (el que implementaste en el nivel +2) y el caso "no encontrado" de `getRecipeById(...)` **a nivel de +`RecipeService`**, no de `RecipeController` — `RecipeServiceTest.java` ya +prueba el caso en que la receta sí existe, pero nadie probó todavía qué +pasa cuando no. El archivo ya trae la configuración de `@Mock`/`@InjectMocks` lista, y comentarios con pistas específicas en cada método. diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java index e13eff8..f761f46 100644 --- a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java +++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java @@ -143,18 +143,7 @@ public List findByChefType(String chefType) { * @return Entidad Recipe. */ public Recipe toEntity(RecipeDTO dto) { - edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.model.Chef chef; - Integer season = dto.getSeason(); - if ("Contestant".equals(dto.getChefType())) { - chef = new Contestant(dto.getChefName(), season == null ? 0 : season); - } else if ("Judge".equals(dto.getChefType())) { - chef = new Judge(dto.getChefName()); - } else { - chef = new Viewer(dto.getChefName()); - } - List ingredients = dto.getIngredients().stream().map(Ingredient::new).collect(Collectors.toList()); - List steps = dto.getSteps().stream().map(Step::new).collect(Collectors.toList()); - return new Recipe(dto.getTitle(), ingredients, steps, chef); + throw new UnsupportedOperationException("TODO nivel 3: implementa toEntity"); } /** diff --git a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java index e91b305..8c5e083 100644 --- a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java +++ b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java @@ -18,8 +18,10 @@ * NIVEL 5 - Pruebas unitarias propias (docs/curso/05-pruebas-unitarias). * * A diferencia de los demás tests del proyecto, ESTE ARCHIVO LO ESCRIBES TÚ. - * countRecipes() y getRecipesPage(...) (que ya implementaste en los niveles - * 2 y 7) no tienen ninguna prueba unitaria a nivel de RecipeService todavía. + * countRecipes() (que ya implementaste en el nivel 2) y el caso + * "no encontrado" de getRecipeById(...) no tienen ninguna prueba unitaria a + * nivel de RecipeService todavía — RecipeServiceTest.java solo cubre el + * caso en que la receta sí existe. * * Sigue el patrón AAA (Arrange-Act-Assert) que ya usa RecipeServiceTest.java * en este mismo paquete: @@ -49,9 +51,7 @@ void setUp() { @Test @Tag("nivel5") void testCountRecipes() { - org.mockito.Mockito.when(recipeRepository.count()).thenReturn(5L); - long result = recipeService.countRecipes(); - org.junit.jupiter.api.Assertions.assertEquals(5L, result); + fail("TODO nivel 5: implementa testCountRecipes"); } /** @@ -61,22 +61,18 @@ void testCountRecipes() { @Test @Tag("nivel5") void testCountRecipes_whenEmpty() { - org.mockito.Mockito.when(recipeRepository.count()).thenReturn(0L); - long result = recipeService.countRecipes(); - org.junit.jupiter.api.Assertions.assertEquals(0L, result); + fail("TODO nivel 5: implementa testCountRecipes_whenEmpty"); } /** - * TODO nivel 5: prueba que getRecipesPage(page, size) delega en - * recipeRepository.findAll(PageRequest.of(page, size)) y devuelve el - * Page resultante. Pista: puedes usar - * new org.springframework.data.domain.PageImpl<>(List.of(new Recipe())) - * como valor de retorno simulado del repositorio. + * TODO nivel 5: prueba que getRecipeById(id) devuelve un Optional vacío + * cuando recipeRepository.findById(id) no encuentra nada (usa + * when(recipeRepository.findById(...)).thenReturn(Optional.empty())). */ @Test @Tag("nivel5") - void testGetRecipesPage() { - fail("TODO nivel 5: implementa testGetRecipesPage"); + void testGetRecipeById_notFound() { + fail("TODO nivel 5: implementa testGetRecipeById_notFound"); } // Pista de datos de ejemplo por si la necesitas: From d764bee581e4f82e729384085c1347c929d013cc Mon Sep 17 00:00:00 2001 From: DannielEU Date: Fri, 7 Aug 2026 00:45:29 -0500 Subject: [PATCH 3/3] test: Update RecipeDatabaseHealthIndicatorTest to use lenient stubbing for repository exceptions --- .../health/RecipeDatabaseHealthIndicatorTest.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java index a8b2d41..b8ddc50 100644 --- a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java +++ b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java @@ -12,6 +12,8 @@ import org.springframework.boot.actuate.health.Status; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -48,10 +50,18 @@ void testHealth_repositoryResponds_reportsUp() { @Tag("nivel12") @DisplayName("Debe reportar DOWN cuando el repositorio lanza una excepcion") void testHealth_repositoryFails_reportsDown() { - when(recipeRepository.count()).thenThrow(new RuntimeException("mongo no responde")); + // lenient(): antes de completar el TODO, doHealthCheck lanza + // UnsupportedOperationException sin siquiera llegar a llamar + // recipeRepository.count() - con stubbing estricto Mockito + // marcaria ese stub como "no usado" y fallaria el test con un + // error confuso en vez de la razon real (que count() nunca se + // llamo). El verify(...) de abajo es quien realmente exige que tu + // implementacion haya intentado consultar el repositorio. + lenient().when(recipeRepository.count()).thenThrow(new RuntimeException("mongo no responde")); Health health = healthIndicator.health(); assertEquals(Status.DOWN, health.getStatus()); + verify(recipeRepository).count(); } }