Spring SecurityLección 14
Testing de seguridad
La configuración de seguridad es código que nadie ejecuta en el camino feliz. Tests de reglas con MockMvc, de seguridad por método, la matriz de autorización y tokens reales.
Al terminar sabrás
- Simular usuarios, JWT y tokens CSRF en tests con
MockMvc. - Probar la seguridad por método con
@WithMockUser. - Construir una matriz de autorización parametrizada que delate endpoints sin proteger.
- Distinguir qué prueba un test con
jwt()y qué necesita un test de integración.
Si una regla de seguridad está mal, la aplicación sigue funcionando y nadie se entera hasta que alguien la explota. Por eso la seguridad merece tests propios, y sobre todo tests negativos: comprobar que lo prohibido falla con el código correcto.
Dependencias (en Spring Boot 4 los starters de test van por módulo):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security-test</artifactId> <scope>test</scope></dependency><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc-test</artifactId> <scope>test</scope></dependency>Las herramientas
Sección titulada «Las herramientas»| Herramienta | Qué hace | Cuándo |
|---|---|---|
@WithMockUser(username = "ana", roles = "USER") | Pone un usuario ficticio en el SecurityContext | Seguridad por método, controladores con sesión |
@WithUserDetails("ana") | Carga el usuario real de tu UserDetailsService | Cuando el principal debe ser tu clase |
.with(jwt().jwt(j -> j.subject("ana")).authorities(…)) | Simula un JWT ya validado, sin claves ni firma | APIs con resource server |
.with(httpBasic("ana", "…")) | Envía credenciales reales | Probar el login de verdad |
.with(csrf()) | Añade un token CSRF válido | POST/PUT/DELETE en cadenas con CSRF |
.with(anonymous()) | Fuerza usuario anónimo | Probar 401 |
authenticated().withAuthorities(…) | Comprueba con qué authorities quedó el usuario | Tests de login |
Los métodos estáticos están en SecurityMockMvcRequestPostProcessors y SecurityMockMvcResultMatchers.
Test de la capa web
Sección titulada «Test de la capa web»@WebMvcTest carga solo los controladores y la configuración de seguridad: es rápido. Importa tu SecurityConfig explícitamente y simula los servicios:
@WebMvcTest(NoteController.class)@Import(SecurityConfig.class)class NoteControllerTest {
@Autowired MockMvc mvc; @MockitoBean NoteService notes;
@Test void anonimo_recibe401() throws Exception { mvc.perform(get("/api/notes").with(anonymous())) .andExpect(status().isUnauthorized()); }
@Test void lector_puedeListarSusNotas() throws Exception { mvc.perform(get("/api/notes") .with(jwt().jwt(j -> j.subject("ana")) .authorities(new SimpleGrantedAuthority("SCOPE_notes:read")))) .andExpect(status().isOk()); }
@Test void sinScopeAdmin_noVeTodasLasNotas() throws Exception { mvc.perform(get("/api/admin/notes") .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_notes:read")))) .andExpect(status().isForbidden()); }}Test de seguridad por método
Sección titulada «Test de seguridad por método»Las anotaciones @PreAuthorize solo funcionan sobre el proxy de Spring, así que el test necesita el contexto:
@SpringBootTestclass NoteServiceSecurityTest {
@Autowired NoteService service;
@Test @WithMockUser(username = "ana", authorities = "SCOPE_notes:read") void usuarioNormal_noPuedeListarTodo() { assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> service.all()); }
@Test @WithMockUser(username = "root", authorities = "SCOPE_notes:admin") void admin_puedeListarTodo() { assertThatNoException().isThrownBy(() -> service.all()); }}La matriz de autorización
Sección titulada «La matriz de autorización»La técnica más rentable: una tabla de endpoint × tipo de usuario → código esperado, convertida en un test parametrizado. Cuando alguien añade un endpoint y olvida protegerlo, la matriz lo delata.
@WebMvcTest(NoteController.class)@Import(SecurityConfig.class)class AuthorizationMatrixTest {
@Autowired MockMvc mvc; @MockitoBean NoteService notes;
static Stream<Arguments> matrix() { return Stream.of( arguments("GET", "/api/notes", "anonymous", 401), arguments("GET", "/api/notes", "reader", 200), arguments("POST", "/api/notes", "reader", 403), arguments("POST", "/api/notes", "writer", 200), arguments("DELETE", "/api/notes/1", "reader", 403), arguments("GET", "/api/admin/notes", "writer", 403), arguments("GET", "/api/admin/notes", "admin", 200)); }
@ParameterizedTest(name = "{0} {1} como {2} → {3}") @MethodSource("matrix") void respetaLaMatriz(String method, String path, String profile, int expected) throws Exception { mvc.perform(request(HttpMethod.valueOf(method), path) .with(as(profile)) .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"t\",\"content\":\"c\"}")) .andExpect(status().is(expected)); }
private static RequestPostProcessor as(String profile) { return switch (profile) { case "anonymous" -> anonymous(); case "reader" -> jwt().authorities(new SimpleGrantedAuthority("SCOPE_notes:read")); case "writer" -> jwt().authorities(new SimpleGrantedAuthority("SCOPE_notes:read"), new SimpleGrantedAuthority("SCOPE_notes:write")); case "admin" -> jwt().authorities(new SimpleGrantedAuthority("SCOPE_notes:admin")); default -> throw new IllegalArgumentException(profile); }; }}(Con servicios simulados, los casos que pasan la seguridad devuelven lo que configures en el mock; lo que importa de la matriz son los 401 y 403).
Tokens reales en integración
Sección titulada «Tokens reales en integración»Para probar la validación del token de verdad, firma tokens con tu JwtEncoder (o una clave de test) y envíalos:
@SpringBootTest@AutoConfigureMockMvcclass JwtValidationIT {
@Autowired MockMvc mvc; @Autowired JwtEncoder encoder;
@Test void tokenCaducado_recibe401() throws Exception { Instant past = Instant.now().minus(1, ChronoUnit.HOURS); JwtClaimsSet claims = JwtClaimsSet.builder() .issuer("https://notes.local").subject("ana").audience(List.of("notes-api")) .issuedAt(past.minus(15, ChronoUnit.MINUTES)).expiresAt(past) .claim("scope", "notes:read").build(); String token = encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
mvc.perform(get("/api/notes").header("Authorization", "Bearer " + token)) .andExpect(status().isUnauthorized()) .andExpect(header().string("WWW-Authenticate", containsString("invalid_token"))); }}Mini lab · 40 min
Blindar la API de notas con tests
Objetivo: Que ningún cambio futuro pueda abrir un agujero sin que falle el build.
- Escribe la matriz completa para todos los endpoints de la API de notas con los perfiles anónimo, lector, escritor y admin.
- Escribe un test de integración (
@SpringBootTest+@AutoConfigureMockMvc) que cree dos usuarios reales, cree una nota con el primero y verifique que el segundo recibe 404 al pedirla y no puede borrarla. - Añade un test que firme un token caducado y compruebe el 401.
Preguntas:
- Comenta temporalmente la regla de
/api/admin/**. ¿Qué test falla? Si ninguno falla, ¿qué significa? - ¿Por qué el test con
jwt()sigue pasando aunque sustituyas elJwtDecoderreal por uno que rechaza todo? - ¿Qué tests pondrías en
@WebMvcTesty cuáles en@SpringBootTest?
Pista
Piensa en qué componentes se cargan en cada tipo de test y cuánto tarda cada uno.
Mostrar respuestaOcultar respuesta
- Deberían fallar las filas de
/api/admin/notescon perfiles lector y escritor (esperaban 403 y ahora reciben 200, porque la petición cae en una regla más permisiva). Si ninguno falla, tu matriz tiene un hueco: falta probar ese endpoint con un perfil sin permiso. - Porque
jwt()no pasa por elJwtDecoder: coloca directamente unaJwtAuthenticationTokenen el contexto. Prueba la autorización, no la validación del token. Por eso hace falta el test de integración con tokens firmados de verdad. - En
@WebMvcTest(rápidos, se ejecutan cientos en segundos): la matriz de autorización, los códigos 401/403, CSRF y CORS. En@SpringBootTest(más lentos, pocos y valiosos): el aislamiento entre usuarios con base de datos real (IDOR), la validación de tokens reales y la seguridad por método con proxies.