1. Pruebas de ruta negativa: ingeniería
Cuando escribimos pruebas de seguridad, es muy fácil caer en la trampa de «lo principal es que funcione». Pero la seguridad se comprueba realmente no porque el administrador pueda entrar al panel de administración, sino porque el no administrador no puede. Y no «en promedio», sino en cada escenario importante: sin inicio de sesión, con un rol ajeno, con un token roto, intentando acceder a datos de otro. En este punto, las pruebas se convierten en una especie de alarma: no decoran la casa, gritan cuando alguien (incluyéndote a ti en el futuro) accidentalmente dejó la puerta abierta.
Para no tenerlo todo en la cabeza, es útil acordar de antemano qué clases de escenarios negativos comprobamos en general, y qué códigos de estado consideramos correctos. A nivel del curso es conveniente verlo como un «mapa de fallos»:
| Qué salió mal | Esto es sobre… | Normalmente esperamos | Cómo modelar convenientemente en MockMvc |
|---|---|---|---|
| Usuario no autenticado | authentication | 401 | solicitud sin .with(user()) / sin .with(jwt()) |
| Usuario autenticado, pero sin permiso | authorization | 403 | .with(user("...").roles(...)) o .with(jwt().authorities(...)) |
| Bearer token roto (malformed/expired/invalid signature) | authentication (no se pudo recuperar el usuario) | 401 | cabecera real Authorization: Bearer ... |
| Usuario accede a un objeto ajeno | business authorization / ownership | 403 (a veces 404) |
dos usuarios diferentes en el mismo id del recurso |
Observa algo importante: jwt() es «mostrar rápidamente que el usuario está autenticado». Pero jwt() no modela «token roto» porque evita la ruta de análisis y validación. Por eso en esta lección separaremos conscientemente: dónde probamos el permiso de acceso (jwt() como contexto) y dónde probamos el hecho de que el token no se lee / no es válido (cabecera Authorization real).
2. Modelo mental: dónde nacen 401 y 403
Sinceramente, 401 y 403 son como «no te dejaron entrar al club», pero por razones diferentes. 401 estás en la entrada sin pulsera (o con una pulsera que el guardia no pudo reconocer). 403 la pulsera es auténtica, pero no te dejan entrar a la zona VIP porque no eres VIP. Para las pruebas esto es crítico: un estado incorrecto rompe el contrato de la API, y el cliente empieza a comportarse de manera extraña. Y al final, «por qué la aplicación móvil a veces pide iniciar sesión y a veces simplemente dice “sin acceso”» se convierte en una pregunta al estilo de los antiguos filósofos.
En Spring Security esta ruptura suele pasar por la línea de AuthenticationEntryPoint (para 401) y AccessDeniedHandler (para 403). Ahora no vamos a reescribir la configuración, sino que comprobaremos que ya se comporta como acordamos en el proyecto: para REST API no hay redirección a una página HTML de inicio de sesión, sino un predecible 401/403.
Ayuda mucho tener en la cabeza un mini árbol de decisiones. Se puede representar así:
flowchart TD
%% Breve recordatorio: primero autenticación (401), luego autorización (403)
A[Solicitud llegó a SecurityFilterChain] --> B{¿Se pudo recuperar Authentication?}
B -->|No| C[AuthenticationEntryPoint → 401]
B -->|Sí| D{¿Tiene permiso para la acción?}
D -->|No| E[AccessDeniedHandler → 403]
D -->|Sí| F[Solicitud pasa al controlador/servicio]
En las pruebas se lee aún más fácil: si no añadimos .with(user()) o .with(jwt()), entonces estamos comprobando conscientemente la rama 401. Si añadimos autenticación, pero deliberadamente «no damos» el rol/authority/ownership, comprobamos la rama 403.
3. 401 Unauthorized en las pruebas: cuando el usuario «no inició sesión»
Con 401 en las pruebas hay una agradable simplicidad: para obtenerlo basta con no crear un contexto de autenticación. Pero también hay una trampa desagradable: a veces obtienes no 401, sino un redireccionamiento 302 (al estilo stateful/browser) o incluso 403 (por ejemplo, debido a CSRF en POST). Por eso, en buenas pruebas negativas procuramos que la causa de la denegación sea una y obvia: «el usuario no está autenticado». Para ello conviene empezar con endpoints GET, donde CSRF no interfiere.
A continuación, un ejemplo mínimo para nuestro proyecto Secure Content Platform API: /api/me debe ser accesible solo para autenticados, y una solicitud anónima debe recibir 401.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
@Test
void meEndpointWhenAnonymousThenUnauthorized() throws Exception {
// Importante: no añadimos .with(user())/.with(jwt()) — comprobamos precisamente la rama 401 (sin autenticación)
mvc.perform(get("/api/me"))
.andExpect(status().isUnauthorized());
}
Si esta prueba de repente devuelve 302, no es «la prueba es mala», es una pista: tu zona API posiblemente todavía vive con lógica de redirección de navegador. En el marco del curso apuntamos a un comportamiento REST-friendly, por lo que 401 es un objetivo esperado normal.
Aquí hay otro límite importante: el escenario de token roto debe pasar por el path JWT que realmente vive en la aplicación. Si tienes habilitado el camino built-in resource-server, la solicitud debe caer en la cadena de procesamiento bearer como BearerTokenAuthenticationFilter y JwtDecoder. Si tienes tu propia rama JWT, el error debe nacer en tu filter personalizado y llegar a ese AuthenticationEntryPoint que maneja esa denegación. jwt() no es adecuado aquí en ambos casos: crea inmediatamente un Authentication ya preparado y evita el lugar donde el token debería haberse roto.
Ahora un caso un poco más sutil: malformed bearer token. Aquí el usuario tampoco «ha iniciado sesión», pero no porque falte la cabecera, sino porque la cabecera existe y el token no se puede procesar. Y esto ya no se puede probar mediante jwt(), porque jwt() es «insertar un Jwt ya preparado en SecurityContext», saltándose el análisis de la cabecera.
Por lo tanto, probamos con una cabecera real:
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
@Test
void meEndpointWhenMalformedBearerTokenThenUnauthorized() throws Exception {
// Aquí es esencial la cabecera Authorization real:
// jwt() evita el análisis/validación del token, pero queremos comprobar que lo "roto" no autentica
mvc.perform(get("/api/me")
.header("Authorization", "Bearer broken-token"))
.andExpect(status().isUnauthorized());
}
Aquí es importante precisamente lo que comprobamos: «un token roto no se convierte en autenticación». Es una buena prueba de regresión: si mañana alguien «mejora» el filtro de manera que empiece a tragarse los errores y a dejar pasar la solicitud como anónima, o (aún más divertido) autentique a «alguien» con una cadena basura, la prueba se volverá roja.
A veces se quiere probar otra variedad de 401: la ausencia de token donde el endpoint está protegido por un modelo stateless. En su forma más simple se ve igual que el primer ejemplo: simplemente no añadimos .with(jwt()) y esperamos 401. La diferencia es que mentalmente lo asociamos no con un inicio de sesión basado en sesión, sino con «en la rama stateless cada solicitud debe llevar un bearer token».
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
@Test
void editorQueueWhenNoBearerTokenThenUnauthorized() throws Exception {
// La solicitud sin token debe ser rechazada a la entrada, antes del controlador
mvc.perform(get("/api/editor/review-queue"))
.andExpect(status().isUnauthorized());
}
Y el escenario de expired token ya es ese momento donde eliges una estrategia. Si quieres comprobar precisamente la ruta de validación real (y no solo el acceso basado en roles), necesitas un token realmente válido en formato pero con exp caducado. Esto se puede hacer a través de tu TokenService en las pruebas, pero entonces la prueba se vuelve un poco «más infraestructural». La idea importante aquí es: tales pruebas deben ser raras y puntuales, porque no queremos convertir cada prueba de autorización en una aventura criptográfica.
4. 403 Forbidden en las pruebas: cuando el usuario ha iniciado sesión pero no tiene permiso
Con 403 la situación es simétrica: ahora nuestro objetivo es lograr que la solicitud se autentique, pero luego sea rechazada por falta de permisos. Si accidentalmente no autenticas la solicitud, obtendrás 401 y la prueba no estará comprobando lo que debía. Por eso, en las pruebas negativas de 403 casi siempre hay un «actor explícito»: user("maria").roles("USER") o jwt().authorities(...). Es como una credencial en una conferencia: «he llegado, existo, pero si me dejarán entrar a esta sección es otra cuestión».
El caso clásico del proyecto: un usuario con rol USER intenta hacer una acción de editor, por ejemplo publicar un borrador. En la rama JWT es más fácil comprobarlo mediante jwt() (porque ahora no nos importa la firma del token, nos importa la regla de acceso).
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
@Test
void publishDraftWhenUserRoleThenForbidden() throws Exception {
// Hay autenticación (jwt()), pero el rol es deliberadamente "incorrecto" — comprobamos autorización (403), no 401
mvc.perform(post("/api/editor/drafts/15/publish")
.with(jwt().authorities(() -> "ROLE_USER")))
.andExpect(status().isForbidden());
}
Observa la «belleza» de la causa de denegación: la solicitud está autenticada porque jwt() ha creado un JwtAuthenticationToken, pero las authorities son insuficientes. Esa es precisamente la prueba que captura regresiones del tipo «uy, cambiamos accidentalmente el matcher y ahora publish está disponible para todos los que han iniciado sesión».
Se puede hacer la misma prueba para la zona de administración. Por ejemplo, editor no es admin y no debería poder ver la lista de usuarios.
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
@Test
void listUsersWhenEditorThenForbidden() throws Exception {
// El usuario está autenticado, pero tiene ROLE_EDITOR en lugar del rol esperado de admin
mvc.perform(get("/api/admin/users")
.with(jwt().authorities(() -> "ROLE_EDITOR")))
.andExpect(status().isForbidden());
}
Si en tu proyecto hay reglas más finas a nivel de authorities (por ejemplo, user:read, draft:publish), entonces en esencia la prueba será la misma, solo que en lugar de ROLE_* comprobarás la palabra de permiso concreta. Lo importante no es el nombre de la cadena, sino que la prueba refleje claramente el acuerdo: «este actor no tiene derecho a esta acción».
Hay otro matiz: muchos endpoints que «por negocio» deberían dar 403 técnicamente son POST/PATCH/DELETE. En la rama basada en sesión, además puede adherirse CSRF. Si pruebas la denegación basada en rol, pero olvidaste .with(csrf()), obtendrás un 403 por CSRF, no por rol. Formalmente el código coincide, pero el sentido de la prueba desaparece. Es el momento en que la prueba se convierte en una ilusión de seguridad: verde, pero inútil.
5. «Objeto ajeno»: no es simplemente otro 403
Las reglas basadas en ownership son el lugar donde el principiante suele entender por primera vez que la seguridad no es solo sobre roles. El rol USER lo tienen ambos usuarios, pero el acceso al borrador debe ser solo del propietario. Y si no cubres esto con una prueba, podrías abrir accidentalmente datos ajenos con un pequeño cambio en el servicio. En esos momentos es especialmente frustrante que «todas las pruebas están verdes», porque solo probaste roles, no la pertenencia.
En el proyecto esto se expresa de manera muy concreta: GET /api/drafts/{id} debe ser accesible para el propietario del borrador, pero inaccesible para otro usuario. Para que la prueba sea buena, necesitamos dos cosas. Primera: datos estables; debemos saber a quién pertenece el draft 15. Segunda: dos actores: el propietario y «no propietario».
La parte positiva del par (propietario) se ve así:
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
@Test
void readDraftWhenOwnerThenOk() throws Exception {
// El mismo id=15, pero el actor es el propietario: esta es la mitad "positiva" de control del par
mvc.perform(get("/api/drafts/15")
.with(user("maria").roles("USER")))
.andExpect(status().isOk());
}
Y esto es por lo que estamos aquí: la parte negativa (usuario ajeno):
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
@Test
void readDraftWhenForeignUserThenForbidden() throws Exception {
// Importante: el id es el mismo (15), solo cambia el usuario — para que la causa del 403 sea precisamente "no propietario"
mvc.perform(get("/api/drafts/15")
.with(user("anna").roles("USER")))
.andExpect(status().isForbidden());
}
Y esto ya es un verdadero «guardián contra fugas». Captura regresiones no a nivel de configuración de URL, sino a nivel de autorización de negocio: método security, ownership-check en el servicio, lógica de lectura del borrador. Si la prueba de repente se vuelve verde con 200, significa que accidentalmente entregaste datos ajenos. Y sí, esto suele ser más importante que «tenemos un logger incorrecto en alguna parte».
Por cierto, en productos reales a veces se elige la estrategia de devolver 404 Not Found para un objeto ajeno, para no revelar la existencia del recurso. En el proyecto educativo solemos dejar 403, porque muestra más claramente el significado de «acceso denegado». Lo principal es que sea consistente y esté fijado por una prueba: nos pusimos de acuerdo y lo comprobamos.
6. Hacer pruebas negativas cortas
Las pruebas de seguridad negativas se convierten fácilmente en un género extraño: «hicimos cinco acciones, luego esperamos un estado, pero por qué es ese, no lo recordamos». Para evitar esto, es útil adoptar una regla casi aburrida: una prueba, una causa de denegación. Si compruebas 401, no añadas autenticación. Si compruebas 403 por rol, añade autenticación y asegúrate de que no te interfiera CSRF u otras condiciones.
Ayuda mucho el estilo de nomenclatura de pruebas donde se escribe directamente «quién», «dónde» y «qué esperamos». Por ejemplo, meEndpointWhenAnonymousThenUnauthorized es una mini especificación. Se puede leer en un informe de CI y entender el significado sin siquiera abrir el código. Compáralo con testMeEndpoint1: un nombre que no sirve para casi nada, excepto para aumentar el nivel de ansiedad.
Otro punto práctico: no intentes «probarlo todo a la vez». Si en una prueba compruebas simultáneamente que el usuario no puede publicar, no puede eliminar el borrador ajeno y recibe 401 sin token, cuando falle estarás adivinando cuál de esas cosas se rompió. Tres pruebas cortas te darán más tranquilidad que una gigantesca.
Y por último, recuerda la diferencia entre herramientas de prueba: jwt() es bueno cuando pruebas la decisión de authorization (rol/authority/acceso). La cabecera real Authorization: Bearer ... es necesaria cuando pruebas la ruta de authentication (el token no se analiza, la firma no coincide, el token ha caducado). Si confundes estos dos mundos, la prueba será o inútil o frágil.
7. Errores típicos en pruebas de ruta negativa
Error n.º 1: confundir 401 y 403.
Si en la prueba no hay .with(user())/.with(jwt()) y esperas 403, lo más probable es que no estés comprobando una prohibición de acceso, sino la ausencia de autenticación. Es similar a la situación en la que te quejas de que «el guardia no me deja entrar al VIP», aunque llegaste sin entrada.
Error n.º 2: considerar 403 como éxito sin entender la causa.
En la rama basada en sesión, 403 puede ser tanto por CSRF como por AccessDenied. Si pruebas el rol en POST/PATCH/DELETE y olvidaste .with(csrf()), entonces la prueba «sobre el rol» se convierte en una prueba «sobre CSRF», incluso si su nombre es bonito y seguro. Será verde, pero tendrá aproximadamente tanta utilidad como un paraguas en un submarino.
Error n.º 3: probar «token roto» mediante jwt().
Esto es imposible por definición: jwt() no analiza la cadena de la cabecera ni valida la firma, simplemente crea un JwtAuthenticationToken ya preparado. Por lo tanto, malformed token, expired token, wrong signature son escenarios para la cabecera Authorization real y tu procesamiento bearer/JWT real.
Error n.º 4: en el escenario de «objeto ajeno», usar accidentalmente datos del propietario.
Esto ocurre especialmente a menudo cuando los datos de prueba no están fijados o se crean «de alguna manera». Como resultado, tienes una prueba con el nombre foreignUserThenForbidden que consistentemente obtiene 200, pero no te das cuenta porque, por ejemplo, no comprueba el estado, sino solo algo en el JSON. Aquí ayuda la disciplina: para un escenario basado en ownership, haz siempre un par «propietario/ajeno» sobre el mismo id.
Error n.º 5: mezclar varias causas de denegación en una sola prueba.
Por ejemplo, simultáneamente «sin rol» y «sin CSRF» y «usuario incorrecto». Esta prueba puede fallar por cualquier razón, y perderás tiempo no entendiendo la seguridad, sino haciendo arqueología de tus propias expectativas. En seguridad, por extraño que parezca, es mejor ser aburrido y predecible: prueba corta, una solicitud, un estado esperado, una causa clara.
GO TO FULL VERSION