Ir al contenido

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.

Avanzado16 min de lectura

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):

pom.xml
<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>
HerramientaQué haceCuándo
@WithMockUser(username = "ana", roles = "USER")Pone un usuario ficticio en el SecurityContextSeguridad por método, controladores con sesión
@WithUserDetails("ana")Carga el usuario real de tu UserDetailsServiceCuando el principal debe ser tu clase
.with(jwt().jwt(j -> j.subject("ana")).authorities(…))Simula un JWT ya validado, sin claves ni firmaAPIs con resource server
.with(httpBasic("ana", "…"))Envía credenciales realesProbar el login de verdad
.with(csrf())Añade un token CSRF válidoPOST/PUT/DELETE en cadenas con CSRF
.with(anonymous())Fuerza usuario anónimoProbar 401
authenticated().withAuthorities(…)Comprueba con qué authorities quedó el usuarioTests de login

Los métodos estáticos están en SecurityMockMvcRequestPostProcessors y SecurityMockMvcResultMatchers.

@WebMvcTest carga solo los controladores y la configuración de seguridad: es rápido. Importa tu SecurityConfig explícitamente y simula los servicios:

NoteControllerTest.java
@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());
}
}

Las anotaciones @PreAuthorize solo funcionan sobre el proxy de Spring, así que el test necesita el contexto:

NoteServiceSecurityTest.java
@SpringBootTest
class 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 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.

AuthorizationMatrixTest.java
@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).

Para probar la validación del token de verdad, firma tokens con tu JwtEncoder (o una clave de test) y envíalos:

JwtValidationIT.java
@SpringBootTest
@AutoConfigureMockMvc
class 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.

  1. Escribe la matriz completa para todos los endpoints de la API de notas con los perfiles anónimo, lector, escritor y admin.
  2. 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.
  3. 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 el JwtDecoder real por uno que rechaza todo?
  • ¿Qué tests pondrías en @WebMvcTest y 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/notes con 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 el JwtDecoder: coloca directamente una JwtAuthenticationToken en 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.