From 4564060e039b7dab732095729b87ffc678c4332e Mon Sep 17 00:00:00 2001 From: AsierCL Date: Mon, 10 Aug 2026 15:02:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Implemento=20cache=20en=20varias=20peti?= =?UTF-8?q?ci=C3=B3ns=20que=20necesitan=20esto,=20y=20preparo=20rate=20lim?= =?UTF-8?q?it=20con=20X-Forwarded-For?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Documentation/DEVOPS.md | 49 +++++++++++++------ gradox2/pom.xml | 4 ++ .../example/gradox2/Gradox2Application.java | 2 + .../controller/SubjectController.java | 21 +++----- .../gradox2/security/InMemoryRateLimiter.java | 4 ++ .../gradox2/security/RateLimitFilter.java | 11 ++++- .../implementation/SubjectServiceImpl.java | 43 ++++++++++++++++ .../service/interfaces/ISubjectService.java | 11 +++++ .../resources/application-local.properties | 4 ++ .../resources/application-prod.properties | 4 ++ .../gradox2/AuthRateLimitIntegrationTest.java | 33 ++++++++++--- .../gradox2/SubjectIntegrationTest.java | 47 ++++++++++++++++++ 12 files changed, 194 insertions(+), 39 deletions(-) create mode 100644 gradox2/src/main/java/com/example/gradox2/service/implementation/SubjectServiceImpl.java create mode 100644 gradox2/src/main/java/com/example/gradox2/service/interfaces/ISubjectService.java diff --git a/Documentation/DEVOPS.md b/Documentation/DEVOPS.md index 4748ef0..3cdb644 100644 --- a/Documentation/DEVOPS.md +++ b/Documentation/DEVOPS.md @@ -319,7 +319,7 @@ de verificación serán inutilizables para el usuario final. | **Credenciales S3 por entorno** (`S3_ACCESS_KEY`/`S3_SECRET_KEY`) | ✅ | | **Bootstrap del primer MASTER** vía env (`BOOTSTRAP_MASTER_*`), sin logs de credenciales | ✅ | | **Swagger/OpenAPI desactivado en prod** | ✅ | -| Rate limiting en login/register (in-memory por IP remota) | ✅ (parcial, ver abajo) | +| Rate limiting en login/register (in-memory por IP del cliente vía `X-Forwarded-For`) | ✅ | | Rotación de logs (tamaño/fecha) | ✅ | | Límites de memoria/CPU por contenedor | ✅ | | Actuator expuesto solo `health` e `info` | ✅ | @@ -327,28 +327,44 @@ de verificación serán inutilizables para el usuario final. | Datos demo (`DataLoader`) **solo** en perfiles `local`/`demo` | ✅ | | CSRF desactivado (API stateless con JWT) | ✅ | +### Rate limiting y proxys (IP real del cliente) + +El `RateLimitFilter` identifica al cliente usando `X-Forwarded-For`: toma la +**primera** IP de la cabecera (la más cercana al cliente) y, si la cabecera no +viene o viene vacía, cae a `request.getRemoteAddr()`. + +Esto se combina con `server.forward-headers-strategy=framework` +(`application-local.properties` y `application-prod.properties`), que hace que +Spring interprete los headers de proxy (`X-Forwarded-For`, `X-Forwarded-Proto`, +`Forwarded`) y genere esquemas/URLs correctos detrás de un terminador TLS. + +> ⚠️ **Frontera de confianza:** `X-Forwarded-For` **solo es fiable** cuando la +> app está **detrás de un proxy/reverse-proxy que sobrescribe la cabecera** +> (Caddy, nginx, Traefik, Render…). Si el puerto 8080 se expone **directamente**, +> un cliente puede falsificar la cabecera y saturar el bucket de otra IP o +> esquivar el límite usando IPs inventadas. Por eso, al introducir TLS, +> hay que colocar el proxy delante y dejar que sea él quien fije +> `X-Forwarded-For`, ignorando el valor que traiga el cliente. + ### Pendientes / riesgos conocidos (por orden de prioridad) 1. **Sin TLS/HTTPS** — decisión del equipo para esta fase. El tráfico (incluido login) va en claro. **Antes de exponer credenciales reales o datos reales, poner un proxy TLS** (Caddy, nginx, Traefik) delante del puerto 8080. 2. **CORS totalmente permisivo** (`*`, con credentials). Pensado para un frontend aún inexistente; restringir `AllowedOriginPatterns` cuando exista frontend fijo. -3. **Rate limiter in-memory basado en `request.getRemoteAddr()`** — detrás de un - proxy/túnel, todos los clientes comparten la IP. Si se introduce TLS/proxy, - usar el header `X-Forwarded-For`. -4. **JWT sin revocación** — los tokens son válidos hasta `JWT_EXPIRATION` +3. **JWT sin revocación** — los tokens son válidos hasta `JWT_EXPIRATION` (24 h). Los refresh tokens sí son revocables (BD). -5. **Backups** — en Supabase la BD se respalda por el proveedor (ver §8); el +4. **Backups** — en Supabase la BD se respalda por el proveedor (ver 8); el bucket R2 se gestiona desde el dashboard. Si se usara Postgres/MInIO local, habría que planificar dump + copia del bucket. -6. **Tokens de verificación/reset enviados por email en claro** — mitigable con +5. **Tokens de verificación/reset enviados por email en claro** — mitigable con HTTPS y rotación corta (por defecto expiran). -7. **`GET /files/**` es `permitAll`** — el acceso al contenido se protege a nivel +6. **`GET /files/**` es `permitAll`** — el acceso al contenido se protege a nivel de servicio (404 si no autorizado) y a la metadata vía `IdentityVisibility`, pero conviene revisar el mapeo al añadir más endpoints. -8. **`/admin/**` mapeado a `hasRole("MASTER")`** — solo existen `ban`/`unban`; +7. **`/admin/**` mapeado a `hasRole("MASTER")`** — solo existen `ban`/`unban`; `logs` y `config` siguen pendientes. -9. **`badges.icon` y `users.profile_picture`** siguen en PostgreSQL como `oid` +8. **`badges.icon` y `users.profile_picture`** siguen en PostgreSQL como `oid` (large objects) — candidatos a migrar a S3 en una iteración futura. --- @@ -503,7 +519,7 @@ Base URL: `http://:8080` (sin HTTPS por ahora). Lista detallada en | Subida/descarga de archivos falla con `S3Exception` | credenciales R2 incorrectas, bucket inexistente o endpoint mal configurado | revisar `S3_*` del `.env`, que el bucket exista en R2 y el API token tenga permisos | | `The authorization header is malformed` / 403 en R2 | región distinta de `auto` o secret incorrecto | `S3_REGION=auto` + regenerar el token | | Descargas con error pero la app levanta | `S3StorageService` no pudo crear el bucket (best-effort) | en MinIO dev el bucket se auto-crea; en R2 debe existir antes | -| Login 429 / rate limited a pesar de IPs distintas | rate limiter in-memory por `getRemoteAddr()` | si hay proxy, ver §7.3 | +| Login 429 / rate limited a pesar de IPs distintas | clientes reales tras el mismo proxy comparten IP, o `X-Forwarded-For` vacío | asegurarse de que el proxy sobrescribe la cabecera; si no, ver §7.3 (frontera de confianza) | | Enlaces de verificación apuntan a `localhost` | `APP_BASE_URL` sin cambiar | fijar URL pública en `Docker/.env` | | No hay forma de crear el primer admin | `BOOTSTRAP_MASTER_*` vacíos | rellenar y reiniciar la app (es idempotente) | | Swagger/OpenAPI no responde en prod | desactivado a propósito | esperado; no re-activar en prod | @@ -514,14 +530,15 @@ Base URL: `http://:8080` (sin HTTPS por ahora). Lista detallada en ## 14. Pendientes del equipo DevOps (hoja de ruta) -1. **TLS/HTTPS** (Caddy/nginx + ajustar `APP_BASE_URL` a `https://...`). +1. **TLS/HTTPS** (Caddy/nginx + ajustar `APP_BASE_URL` a `https://...`). Al + añadirlo, el proxy debe sobrescribir `X-Forwarded-For` para que el rate + limiter (§7.3) use la IP del cliente real. 2. Revisar **IPv6/IPv4** y el uso del **connection pooler** de Supabase según el host. 3. Definir **backup del bucket R2** (lifecycle/versionado) y revisar los backups automáticos de Supabase. 4. Montar `/app/logs` como volumen persistente (hoy efímero). -5. Revisar rate limiter para funcionamiento tras proxy (`X-Forwarded-For`). -6. Restringir CORS al frontend real cuando exista. -7. CI/CD: actualmente no hay pipeline; el build es manual (`docker build` + +5. Restringir CORS al frontend real cuando exista. +6. CI/CD: actualmente no hay pipeline; el build es manual (`docker build` + `docker compose up`). Considerar GitHub Actions (build de imagen + push a registry + deploy por SSH). -8. Migrar `badges.icon` y `users.profile_picture` (aún `oid` en PostgreSQL) a S3. +7. Migrar `badges.icon` y `users.profile_picture` (aún `oid` en PostgreSQL) a S3. diff --git a/gradox2/pom.xml b/gradox2/pom.xml index 35be85b..db5e728 100644 --- a/gradox2/pom.xml +++ b/gradox2/pom.xml @@ -35,6 +35,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-cache + org.springframework.boot spring-boot-devtools diff --git a/gradox2/src/main/java/com/example/gradox2/Gradox2Application.java b/gradox2/src/main/java/com/example/gradox2/Gradox2Application.java index 37a21bb..6ce06e4 100644 --- a/gradox2/src/main/java/com/example/gradox2/Gradox2Application.java +++ b/gradox2/src/main/java/com/example/gradox2/Gradox2Application.java @@ -2,12 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling @EnableAsync +@EnableCaching public class Gradox2Application { public static void main(String[] args) { diff --git a/gradox2/src/main/java/com/example/gradox2/presentation/controller/SubjectController.java b/gradox2/src/main/java/com/example/gradox2/presentation/controller/SubjectController.java index 7e6e223..53b3198 100644 --- a/gradox2/src/main/java/com/example/gradox2/presentation/controller/SubjectController.java +++ b/gradox2/src/main/java/com/example/gradox2/presentation/controller/SubjectController.java @@ -8,11 +8,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.example.gradox2.persistence.entities.Subject; -import com.example.gradox2.persistence.repository.SubjectRepository; import com.example.gradox2.presentation.dto.subject.SubjectResponse; -import com.example.gradox2.service.exceptions.NotFoundException; -import com.example.gradox2.utils.mapper.SubjectMapper; +import com.example.gradox2.service.interfaces.ISubjectService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -24,21 +21,17 @@ @Tag(name = "Asignaturas", description = "Catálogo de asignaturas y cursos para crear propuestas de archivo") public class SubjectController { - private final SubjectRepository subjectRepository; + private final ISubjectService subjectService; - public SubjectController(SubjectRepository subjectRepository) { - this.subjectRepository = subjectRepository; + public SubjectController(ISubjectService subjectService) { + this.subjectService = subjectService; } @GetMapping @Operation(summary = "Listar asignaturas", description = "Devuelve todas las asignaturas ordenadas por curso y código") @ApiResponse(responseCode = "200", description = "Lista de asignaturas devuelta") public ResponseEntity> getAllSubjects() { - List subjects = subjectRepository.findAllByOrderByCourseIdAscCodeAsc() - .stream() - .map(SubjectMapper::toSubjectResponse) - .toList(); - return ResponseEntity.ok(subjects); + return ResponseEntity.ok(subjectService.listAll()); } @GetMapping("/{id}") @@ -48,8 +41,6 @@ public ResponseEntity> getAllSubjects() { @ApiResponse(responseCode = "404", description = "Asignatura no encontrada") }) public ResponseEntity getSubject(@PathVariable Long id) { - Subject subject = subjectRepository.findById(id) - .orElseThrow(() -> new NotFoundException("Subject not found")); - return ResponseEntity.ok(SubjectMapper.toSubjectResponse(subject)); + return ResponseEntity.ok(subjectService.getById(id)); } } \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/security/InMemoryRateLimiter.java b/gradox2/src/main/java/com/example/gradox2/security/InMemoryRateLimiter.java index 043da77..518f08a 100644 --- a/gradox2/src/main/java/com/example/gradox2/security/InMemoryRateLimiter.java +++ b/gradox2/src/main/java/com/example/gradox2/security/InMemoryRateLimiter.java @@ -11,6 +11,10 @@ public class InMemoryRateLimiter { private final ConcurrentHashMap> requestBuckets = new ConcurrentHashMap<>(); + public void clear() { + requestBuckets.clear(); + } + public boolean isAllowed(String key, int maxRequests, long windowMs) { long now = System.currentTimeMillis(); Deque bucket = requestBuckets.computeIfAbsent(key, ignored -> new ArrayDeque<>()); diff --git a/gradox2/src/main/java/com/example/gradox2/security/RateLimitFilter.java b/gradox2/src/main/java/com/example/gradox2/security/RateLimitFilter.java index ece7f0c..30a5039 100644 --- a/gradox2/src/main/java/com/example/gradox2/security/RateLimitFilter.java +++ b/gradox2/src/main/java/com/example/gradox2/security/RateLimitFilter.java @@ -101,7 +101,16 @@ private String authenticatedIdentity() { } private String clientIp(HttpServletRequest request) { - // Do not trust forwarded headers unless a trusted proxy strategy is explicitly configured. + // Confiar en la primera IP de X-Forwarded-For: solo es seguro si un proxy + // de confianza la sobrescribe (server.forward-headers-strategy=framework). + // Si el cliente conecta directo al puerto de la app, puede falsificarla. + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isBlank()) { + String firstIp = forwardedFor.split(",")[0].trim(); + if (!firstIp.isEmpty()) { + return firstIp; + } + } return request.getRemoteAddr(); } } diff --git a/gradox2/src/main/java/com/example/gradox2/service/implementation/SubjectServiceImpl.java b/gradox2/src/main/java/com/example/gradox2/service/implementation/SubjectServiceImpl.java new file mode 100644 index 0000000..0342b67 --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/service/implementation/SubjectServiceImpl.java @@ -0,0 +1,43 @@ +package com.example.gradox2.service.implementation; + +import java.util.List; + +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.gradox2.persistence.entities.Subject; +import com.example.gradox2.persistence.repository.SubjectRepository; +import com.example.gradox2.presentation.dto.subject.SubjectResponse; +import com.example.gradox2.service.exceptions.NotFoundException; +import com.example.gradox2.service.interfaces.ISubjectService; +import com.example.gradox2.utils.mapper.SubjectMapper; + +@Service +public class SubjectServiceImpl implements ISubjectService { + + private final SubjectRepository subjectRepository; + + public SubjectServiceImpl(SubjectRepository subjectRepository) { + this.subjectRepository = subjectRepository; + } + + @Override + @Transactional(readOnly = true) + @Cacheable("subjects") + public List listAll() { + return subjectRepository.findAllByOrderByCourseIdAscCodeAsc() + .stream() + .map(SubjectMapper::toSubjectResponse) + .toList(); + } + + @Override + @Transactional(readOnly = true) + @Cacheable(value = "subjects", key = "#id") + public SubjectResponse getById(Long id) { + Subject subject = subjectRepository.findById(id) + .orElseThrow(() -> new NotFoundException("Subject not found")); + return SubjectMapper.toSubjectResponse(subject); + } +} \ No newline at end of file diff --git a/gradox2/src/main/java/com/example/gradox2/service/interfaces/ISubjectService.java b/gradox2/src/main/java/com/example/gradox2/service/interfaces/ISubjectService.java new file mode 100644 index 0000000..fe08e55 --- /dev/null +++ b/gradox2/src/main/java/com/example/gradox2/service/interfaces/ISubjectService.java @@ -0,0 +1,11 @@ +package com.example.gradox2.service.interfaces; + +import java.util.List; + +import com.example.gradox2.presentation.dto.subject.SubjectResponse; + +public interface ISubjectService { + List listAll(); + + SubjectResponse getById(Long id); +} \ No newline at end of file diff --git a/gradox2/src/main/resources/application-local.properties b/gradox2/src/main/resources/application-local.properties index edde3d1..42e69ef 100644 --- a/gradox2/src/main/resources/application-local.properties +++ b/gradox2/src/main/resources/application-local.properties @@ -31,6 +31,10 @@ spring.servlet.multipart.max-request-size=10MB app.base-url=${APP_BASE_URL:http://localhost:8080} +# IP y scheme del cliente desde cabeceras X-Forwarded-* (require un proxy de +# confianza delante que las sobrescriba; Render lo garantiza en produccion) +server.forward-headers-strategy=framework + # Orden de los tags en Swagger UI. Springdoc tradicionalmente numera los tags # (ej. "01. Autenticación") para controlar el orden, pero eso obliga a renumerar # al insertar una categoría nueva y genera nombres inválidos en los generadores diff --git a/gradox2/src/main/resources/application-prod.properties b/gradox2/src/main/resources/application-prod.properties index 6243a8e..706ac3a 100644 --- a/gradox2/src/main/resources/application-prod.properties +++ b/gradox2/src/main/resources/application-prod.properties @@ -31,6 +31,10 @@ spring.servlet.multipart.max-request-size=10MB app.base-url=${APP_BASE_URL} +# IP y scheme del cliente desde cabeceras X-Forwarded-* (require un proxy de +# confianza delante que las sobrescriba; Render lo garantiza en produccion) +server.forward-headers-strategy=framework + # Swagger/OpenAPI desactivado en produccion (los endpoints dejan de existir) springdoc.api-docs.enabled=false springdoc.swagger-ui.enabled=false diff --git a/gradox2/src/test/java/com/example/gradox2/AuthRateLimitIntegrationTest.java b/gradox2/src/test/java/com/example/gradox2/AuthRateLimitIntegrationTest.java index f07addc..2451917 100644 --- a/gradox2/src/test/java/com/example/gradox2/AuthRateLimitIntegrationTest.java +++ b/gradox2/src/test/java/com/example/gradox2/AuthRateLimitIntegrationTest.java @@ -22,6 +22,7 @@ import com.example.gradox2.persistence.repository.RefreshTokenRepository; import com.example.gradox2.persistence.repository.UserRepository; import com.example.gradox2.persistence.repository.VerificationTokenRepository; +import com.example.gradox2.security.InMemoryRateLimiter; import com.fasterxml.jackson.databind.ObjectMapper; @SpringBootTest @@ -50,8 +51,12 @@ class AuthRateLimitIntegrationTest { @Autowired private PasswordEncoder passwordEncoder; + @Autowired + private InMemoryRateLimiter rateLimiter; + @BeforeEach void setUp() { + rateLimiter.clear(); passwordResetTokenRepository.deleteAll(); refreshTokenRepository.deleteAll(); verificationTokenRepository.deleteAll(); @@ -95,16 +100,14 @@ void loginShouldReturnTooManyRequestsAfterFiveFailuresFromSameIp() throws Except } @Test - void loginRateLimitShouldIgnoreSpoofedForwardedForHeader() throws Exception { - String remoteIp = "10.0.0.11"; - + void loginRateLimitUsesFirstIpFromForwardedForHeader() throws Exception { for (int i = 0; i < 5; i++) { mockMvc.perform(post("/api/auth/login") .with(request -> { - request.setRemoteAddr(remoteIp); + request.setRemoteAddr("10.0.0.11"); return request; }) - .header("X-Forwarded-For", "198.51.100." + i) + .header("X-Forwarded-For", "198.51.100.50") .contentType(MediaType.APPLICATION_JSON) .content(json(Map.of("username", "ratelimituser", "password", "WrongPass123!")))) .andExpect(status().isUnauthorized()) @@ -113,16 +116,32 @@ void loginRateLimitShouldIgnoreSpoofedForwardedForHeader() throws Exception { mockMvc.perform(post("/api/auth/login") .with(request -> { - request.setRemoteAddr(remoteIp); + request.setRemoteAddr("10.0.0.11"); return request; }) - .header("X-Forwarded-For", "203.0.113.200") + .header("X-Forwarded-For", "198.51.100.50") .contentType(MediaType.APPLICATION_JSON) .content(json(Map.of("username", "ratelimituser", "password", "WrongPass123!")))) .andExpect(status().isTooManyRequests()) .andExpect(jsonPath("$.errorCode").value("RATE_LIMIT_EXCEEDED")); } + @Test + void distinctForwardedForIpsDoNotShareRateLimitBucket() throws Exception { + for (int i = 0; i < 6; i++) { + mockMvc.perform(post("/api/auth/login") + .with(request -> { + request.setRemoteAddr("10.0.0.12"); + return request; + }) + .header("X-Forwarded-For", "198.51.100." + i) + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of("username", "ratelimituser", "password", "WrongPass123!")))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("UNAUTHENTICATED_ACCESS")); + } + } + private String json(Object value) throws Exception { return objectMapper.writeValueAsString(value); } diff --git a/gradox2/src/test/java/com/example/gradox2/SubjectIntegrationTest.java b/gradox2/src/test/java/com/example/gradox2/SubjectIntegrationTest.java index d6c9503..a6043ab 100644 --- a/gradox2/src/test/java/com/example/gradox2/SubjectIntegrationTest.java +++ b/gradox2/src/test/java/com/example/gradox2/SubjectIntegrationTest.java @@ -1,5 +1,7 @@ package com.example.gradox2; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -9,7 +11,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.MockMvc; import com.example.gradox2.persistence.entities.Course; @@ -29,12 +33,16 @@ class SubjectIntegrationTest { private CourseRepository courseRepository; @Autowired + private CacheManager cacheManager; + + @MockitoSpyBean private SubjectRepository subjectRepository; @BeforeEach void cleanDatabase() { subjectRepository.deleteAll(); courseRepository.deleteAll(); + cacheManager.getCache("subjects").clear(); } @Test @@ -75,4 +83,43 @@ void getSubjectReturns404WhenMissing() throws Exception { mockMvc.perform(get("/subjects/{id}", 999999L)) .andExpect(status().isNotFound()); } + + @Test + void getAllSubjectsIsServedFromCacheOnSecondRequest() throws Exception { + Course curso = courseRepository.save(Course.builder().code("1").name("Primer curso").build()); + subjectRepository.save(Subject.builder().code("MAT1").name("Matemáticas I").course(curso).build()); + + mockMvc.perform(get("/subjects")).andExpect(status().isOk()); + mockMvc.perform(get("/subjects")).andExpect(status().isOk()); + mockMvc.perform(get("/subjects")).andExpect(status().isOk()); + + verify(subjectRepository, times(1)) + .findAllByOrderByCourseIdAscCodeAsc(); + } + + @Test + void getAllSubjectsRehitsDatabaseAfterCacheEviction() throws Exception { + Course curso = courseRepository.save(Course.builder().code("1").name("Primer curso").build()); + subjectRepository.save(Subject.builder().code("MAT1").name("Matemáticas I").course(curso).build()); + + mockMvc.perform(get("/subjects")).andExpect(status().isOk()); + cacheManager.getCache("subjects").clear(); + + mockMvc.perform(get("/subjects")).andExpect(status().isOk()); + + verify(subjectRepository, times(2)) + .findAllByOrderByCourseIdAscCodeAsc(); + } + + @Test + void getSubjectIsServedFromCacheOnSecondRequest() throws Exception { + Course curso = courseRepository.save(Course.builder().code("1").name("Primer curso").build()); + Subject subject = subjectRepository.save( + Subject.builder().code("MAT1").name("Matemáticas I").course(curso).build()); + + mockMvc.perform(get("/subjects/{id}", subject.getId())).andExpect(status().isOk()); + mockMvc.perform(get("/subjects/{id}", subject.getId())).andExpect(status().isOk()); + + verify(subjectRepository, times(1)).findById(subject.getId()); + } } \ No newline at end of file