CodeGym /Cursos /Spring Test /Pruebas de seguridad mantenibles

Pruebas de seguridad mantenibles

Spring Test
Nivel 23, Lección 4
Disponible

1. El crecimiento de las pruebas de seguridad

Las pruebas de seguridad son campeonas en multiplicación. Una sola operación protegida ya requiere verificar el happy path, pero aquí de repente multiplicamos los escenarios por «quién hace la solicitud o llama al método»: anonymous, editor, admin, además de «de quién es el recurso», y diferentes códigos de error. Si se escribe todo con copia y pega, las pruebas se convierten rápidamente en una serie de 12 temporadas donde nadie recuerda qué pasó en el primer episodio.

La causa más frecuente de un «infierno» de security-suite no es la seguridad en sí, sino que olvidamos que una prueba también es código y que la gente lo lee. Una prueba de seguridad mantenible debe responder tres preguntas literalmente en los primeros dos segundos de visualización: quién hace la solicitud o llama a la operación, qué intenta hacer (endpoint en la frontera HTTP o método de servicio dentro de la aplicación) y qué se espera (permiso/denegación y por qué). Si estas tres cosas están escondidas entre repeticiones de with(user("alice")...), contentType(...), accept(...), andExpect(...), entonces la prueba no actúa como documentación, sino como un cifrado.

Miremos honestamente una prueba típica «escrita ayer» que en una semana ya parece ajena (incluso si la escribiste tú mismo — clásico del género). En los ejemplos siguientes se usa JUnit 6:

import org.junit.jupiter.api.Test;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class AdminApproveSecurityTest {

    @Test
    void editorCannotApprove() throws Exception {
        mockMvc.perform(post("/api/admin/articles/42/approve")
                // Actor del escenario: la solicitud la hace editor (esto es precisamente el objeto de la verificación)
                .with(user("alice").roles("EDITOR")))
            // Esperamos denegación de permisos: editor no puede realizar acción de admin
            .andExpect(status().isForbidden());
    }
}

Mientras es una sola prueba, se ve normal. El problema comienza cuando hay 40 pruebas así, y en cada una se repiten «alice», «EDITOR», «admin», «password», las mismas URL y medias páginas de expectativas idénticas. En ese momento el security-suite empieza a vivir según las reglas del viejo folklore: «Si tocas las pruebas, pueden ofenderse y romperse».

La mantenibilidad aquí no se trata de que sea «bonito», sino de que mañana cambiará una regla de acceso y querrás entender rápidamente: ¿es un solo cambio en un lugar o «manualmente en 25 pruebas»? Un buen security-suite hace que los cambios sean locales y predecibles, no dramáticos.

Es importante no volver a resumir todo en MockMvc. En la práctica, las pruebas de seguridad no son un solo género, sino al menos tres familias, y todas necesitan mantenibilidad.

  1. Frontera HTTP + actor simulado — @WithMockUser, user(...), anonymous(...), cuando verificamos rápidamente la regla de acceso al endpoint.
  2. Frontera HTTP + credenciales reales — httpBasic(...) o el encabezado Authorization normal, cuando es importante pasar por el mecanismo real de inicio de sesión.
  3. Method security + llamada directa a un bean gestionado por Spring — cuando la regla está en un método de servicio y la prueba espera ya no un estado HTTP, sino una excepción como AccessDeniedException.

A continuación, el enfoque principal estará en la primera familia, porque allí es donde más rápido crece la matriz actor → endpoint → resultado. Pero casi todos los hábitos organizativos de esta lección se trasladan a las otras dos vías: nombres significativos de actores, fixtures compactos, agrupación legible de pruebas. Solo no se traslada la forma de las comprobaciones: los helpers de estado son buenos para HTTP, pero la llamada directa a un servicio debe seguir siendo una llamada directa.

2. Agrupación de pruebas y matriz de acceso

En la frontera HTTP, las pruebas de seguridad a menudo parecen una matriz «actor → acción → estado esperado». Por lo tanto, es lógico que la estructura del código de prueba refleje esa matriz. De lo contrario, obtienes un conjunto de verificaciones dispersas, donde la mitad de las pruebas son sobre editor, la mitad sobre admin, y todo mezclado como cables en un cajón que «luego ordenaré». Spoiler: nadie lo ordena.

Hay dos formas saludables de agrupación, y ambas se pueden aplicar en un mismo proyecto. La primera es agrupar por zona de acceso: clases de prueba separadas para las API public/editor/admin. Esto es especialmente cómodo en ContentHub porque las zonas de API ya están reflejadas en las URL (/api/public/..., /api/editor/..., /api/admin/...). La segunda es agrupar por actor dentro de la clase: clases anidadas AsAnonymous, AsEditor, AsAdmin. Este enfoque hace que «quién ejecuta la solicitud» sea visible no solo en el código, sino también en la estructura de las pruebas.

Un esqueleto mínimo se ve así:

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;

@DisplayName("Security: /api/admin/articles")
class AdminArticleSecurityTest {

    @Nested
    @DisplayName("as ANONYMOUS")
    // Las clases anidadas son «carpetas» para los actores: así la matriz de acceso es visible directamente en el árbol de pruebas
    class AsAnonymous {
        // Aquí estarán las pruebas donde la solicitud la hace anonymous
    }
}

¿Por qué es útil especialmente para principiantes? Porque no tendrás la sensación de que la seguridad sea «magia que ocurre dentro de Spring». Ves con tus ojos: aquí están las comprobaciones de anónimo, aquí las de editor, aquí las de administrador. Y cuando alguna prueba falla, ya por el árbol de pruebas entiendes el contexto.

Esquemáticamente (muy burdo, pero ayuda al cerebro):

flowchart TD
    A["AdminArticleSecurityTest"] --> B["AsAnonymous"]
    A --> C["AsEditor"]
    A --> D["AsAdmin"]
    B --> B1["GET/POST -> 401"]
    C --> C1["approve -> 403"]
    D --> D1["approve -> 2xx"]

El mismo truco funciona también para pruebas de method-security: se pueden agrupar por el servicio protegido y dentro separar AsEditor/AsAdmin. Simplemente en la matriz, en lugar de URI estará el nombre del método, y en lugar de 401/403 — permiso o AccessDeniedException.

Observa un detalle sutil pero importante: la agrupación debe mejorar la legibilidad, no añadir burocracia. Si haces 10 niveles de anidación (AsEditor → WhenArticleIsDraft → WhenModerationOk → WhenMercuryIsInRetrograde), las pruebas se parecerán más a un laberinto que a documentación.

Un equilibrio práctico muy bueno para un security-suite se ve así: una clase de prueba cubre una zona o un conjunto de endpoints, y dentro de esa clase, @Nested ayuda a mantener juntas las comprobaciones de diferentes actores. Así, la matriz de acceso se vuelve «visible» directamente en el archivo, sin necesidad de desplazarse 800 líneas hacia abajo.

3. Fixtures de usuario: menos copy-paste

En las pruebas HTTP de seguridad casi siempre se repiten los mismos actores: alice como editor, bob como «otro editor» para escenarios basados en propietario, y admin. Si escribes estas cadenas a mano en cada prueba, estás creando dos problemas: primero, las pruebas se hinchan; segundo, aparecen divergencias como roles("Admin") en un lugar y roles("ADMIN") en otro (y luego te entristeces y buscas por qué de repente aparece 403).

La solución es simple: crear un pequeño conjunto de fixtures de usuario. No una «clase base mágica de 500 líneas», sino literalmente un helper compacto que cree actores de la misma manera en todo el suite. En pruebas MVC, el formato más cómodo es RequestPostProcessor, que se pasa mediante .with(...) a MockMvc.

Una versión mínima de TestUsers podría ser así:

import org.springframework.test.web.servlet.request.RequestPostProcessor;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;

public final class TestUsers {

    private TestUsers() {
        // Clase utilitaria: no se necesitan instancias
    }

    public static RequestPostProcessor editor(String username) {
        // Fixture de actores: forma única de crear un usuario editor para todas las pruebas
        return user(username).roles("EDITOR");
    }
}

Y añadamos inmediatamente un administrador y un usuario anónimo. Lo principal es no complicarlo, sino hacer exactamente lo que ahorra ruido:

import org.springframework.test.web.servlet.request.RequestPostProcessor;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;

public final class TestUsers {

    private TestUsers() {
        // Clase utilitaria: solo métodos static
    }

    public static RequestPostProcessor editor(String username) {
        // Editor se queda en el mismo helper para que todos los actores principales estén reunidos en un lugar
        return user(username).roles("EDITOR");
    }

    public static RequestPostProcessor admin() {
        // Admin fijado explícitamente: un lugar, un username, un rol
        return user("admin").roles("ADMIN");
    }

    public static RequestPostProcessor anonymousUser() {
        // Mostramos explícitamente 'anónimo' como un actor separado, no como null
        return anonymous();
    }
}

Sí, el método admin() en una sola línea parece un poco provocador. Pero este es precisamente ese raro caso en que «provocadoramente corto» es bueno: ahorramos ruido y no escondemos el significado. Una prueba debe verse como una historia: «bob hace PUT allí — esperamos forbidden». Y TestUsers.editor("bob") se lee perfectamente como una frase.

El uso en la prueba también queda muy «humano»:

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class AdminApproveSecurityTest {

    @Test
    void editorGets403OnAdminApprove() throws Exception {
        mockMvc.perform(post("/api/admin/articles/42/approve")
                // No copiamos roles por todo el suite — los tomamos del fixture
                .with(TestUsers.editor("alice")))
            .andExpect(status().isForbidden());
    }
}

Aquí hay una idea metodológica importante: hemos extraído en el helper solo lo que se repite y no aporta significado (crear el usuario), pero no hemos ocultado los elementos clave del escenario (endpoint y estado esperado). Esta es una línea muy fina, y es fácil equivocarse.

A veces el equipo prefiere no usar post-processors, sino @WithMockUser. También está bien, especialmente si agrupas las pruebas por actor mediante @Nested. Entonces puedes poner la anotación en la clase anidada y evitar la repetición:

import org.junit.jupiter.api.Nested;
import org.springframework.security.test.context.support.WithMockUser;

class AdminArticleSecurityTest {

    @Nested
    @WithMockUser(username = "admin", roles = "ADMIN")
    // La anotación se puede poner en la clase anidada para que todas las pruebas dentro se ejecuten «como admin»
    class AsAdmin {
    }
}

Si usas @WithMockUser, es muy útil mantener los nombres de usuario significativos. alice y bob en escenarios basados en propietario funcionan como personajes de un libro: inmediatamente se entiende quién no es «propietario» de quién. Si escribes user1 y user2, el cerebro tendrá que hacer trabajo extra — y en las pruebas precisamente intentamos ahorrar cerebro.

Para la vía de method-security el principio es el mismo: el actor puede definirse mediante una anotación en la clase anidada, pero defaultUser es igual de perjudicial que en las pruebas MockMvc.

4. Escenarios basados en propietario: usuario y datos

El acceso basado en propietario es el lugar donde los principiantes suelen escribir «una prueba aparentemente correcta», pero falla (o pasa) por una razón completamente diferente. Por ejemplo, querías verificar «bob no puede leer el borrador de alice», pero el artículo no se creó — y obtuviste un 404. Formalmente también es «no dejó pasar», pero el significado es otro: ya no es una denegación por propietario, sino ausencia del recurso. Una prueba así induce a error porque no verifica lo que dice su nombre.

Por eso, en las pruebas basadas en propietario, el fixture de datos es una parte tan importante como el fixture de usuario. Necesitas crear explícitamente un artículo, asignarle explícitamente un propietario, y solo entonces hacer la solicitud con un usuario «ajeno». La idea suena obvia, pero es aquí donde más a menudo comienza el caos: «y si tomamos el id 42, que ya existe en alguna parte» (y luego deja de existir).

La forma más sencilla de estabilizar los escenarios basados en propietario es mantener un helper de prueba separado para preparar el artículo, de modo que en la prueba quede el significado, no los detalles de la creación de la entidad. Por ejemplo, una pequeña fábrica TestArticles (en código de prueba) puede crear un artículo «mínimamente válido»:

public final class TestArticles {

    private TestArticles() {
        // Clase utilitaria: fábrica de entidades de prueba
    }

    public static Article draftOwnedBy(String username, Category category) {
        Article a = new Article();
        // Campos mínimamente válidos: para que la prueba falle por seguridad, no por validación/restricciones de BD
        a.setTitle("Test title");
        a.setSummary("Test summary");
        a.setBody("Test body");
        a.setStatus(ArticleStatus.DRAFT);
        // Parte clave de fixture owner-based: a quién pertenece el recurso
        a.setAuthorUsername(username);
        a.setCategory(category);
        return a;
    }
}

Sí, esto ya no es código específico de seguridad, pero resuelve el problema de las pruebas basadas en propietario: los datos se vuelven claros, no aleatorios.

Así es como podría verse la prueba en sí (la idea es más importante que los nombres concretos de repositorios/campos):

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class EditorArticleOwnerSecurityTest {

    @Test
    void bobGets403ForAlicesDraft() throws Exception {
        Category tech = categoryRepository.findByCode("TECH").orElseThrow();
        // Fijamos el propietario del recurso: el artículo pertenece a alice
        Article a = articleRepository.save(TestArticles.draftOwnedBy("alice", tech));

        mockMvc.perform(get("/api/editor/articles/" + a.getId())
                // Fijamos el actor: la solicitud la hace bob (no propietario)
                .with(TestUsers.editor("bob")))
            .andExpect(status().isForbidden());
    }
}

Observa que aquí hay dos fijaciones de contexto. La primera en los datos: el artículo pertenece a alice. La segunda en el actor: la solicitud la realiza bob. Y solo en esta situación el resultado 403 significará realmente «prohibición por propietario», no «casualmente no se encontró el artículo».

Si en tu proyecto se adopta la preparación de datos mediante SQL (y ya sabemos trabajar con @Sql del bloque data/integration del curso), entonces los escenarios basados en propietario pueden hacerse aún más «esquemáticos»: fijar un id concreto de artículo (por ejemplo, 42L) y el propietario directamente en el archivo SQL. A veces esto es incluso más claro que crear una entidad en Java, porque el lector de la prueba ve «aquí hay una fila concreta en la BD» y entiende en qué se basa el escenario.

La regla principal sigue siendo la misma: una prueba basada en propietario debe fallar por la razón «no es el propietario», no por «no existe» o «estado incorrecto».

5. Mini-DSL: helpers sin magia

Una vez que ya tienes fixtures de usuario, surge la tentación de «ir más allá» y escribir un mini-framework para las pruebas: performAsEditor().approve().expectDenied(). Es una tentación dulce porque parece que habrá menos código. Pero aquí es fácil perder lo principal: la prueba deja de ser una simple lectura y se convierte en leer un DSL ajeno (que además tú mismo tienes que mantener).

Un buen punto medio son los helpers que eliminan ruido técnico, pero mantienen los elementos clave del escenario a la vista. Por ejemplo, en las pruebas de seguridad a menudo se repiten accept(MediaType.APPLICATION_JSON) y la combinación constante de .with(actor). Esto se puede extraer en un par de métodos dentro de la clase de prueba (o en una pequeña utilidad), sin ocultar la URL y el estado esperado.

Ejemplo de un helper «saludable»:

import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.RequestPostProcessor;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

ResultActions getAs(RequestPostProcessor actor, String url) throws Exception {
    // Helper elimina ruido técnico (accept + with), pero no oculta URL ni las expectativas (quedan en la prueba)
    return mockMvc.perform(get(url).with(actor).accept(MediaType.APPLICATION_JSON));
}

Ahora la prueba se lee más fácil, pero sigue siendo literal y transparente:

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class EditorArticleSecurityTest {

    @Test
    void anonymousGets401OnEditorRead() throws Exception {
        // Aquí es importante ver explícitamente: anonymous llama a editor-endpoint => esperamos 401
        getAs(TestUsers.anonymousUser(), "/api/editor/articles/42")
            .andExpect(status().isUnauthorized());
    }
}

Observa que no hemos ocultado el endpoint en sí. Esto es importante: en las pruebas HTTP de seguridad, el endpoint es parte del «contrato de acceso» y debe ser visible.

Hay un truco útil que funciona especialmente bien en ContentHub: extraer las plantillas de URL en constantes. No para «poner todo en constantes porque así se acostumbra», sino para no atrapar errores tipográficos y no dispersar el conocimiento de las rutas por todo el suite:

private static final String EDITOR_ARTICLE_BY_ID = "/api/editor/articles/{id}";
private static final String ADMIN_APPROVE = "/api/admin/articles/{id}/approve";

Entonces las pruebas se vuelven aún más claras: no lees «cadenas URL», lees «nombres de acciones».

Para no caer en un «DSL mágico», es útil tener en mente una tabla simple (puede considerarse como un «check» interno):

Qué extraer en un helper Por qué está bien Qué es mejor no extraer Por qué es peligroso
Creación de usuarios (TestUsers.admin()) se repite, no aporta significado estado esperado el lector debe ver 401/403/200 directamente en la prueba
accept(MediaType.APPLICATION_JSON) / contentType(MediaType.APPLICATION_JSON) ruido técnico escenario completo «create + submit + approve» eso ya es un flujo de negocio y se pierde el foco de seguridad
generación de un artículo mínimamente válido para owner-based de lo contrario la prueba es aleatoria «perform() universal» con URL oculta luego es imposible entender qué se está verificando

Si aún quieres reducir un poco la duplicación de comprobaciones de ApiProblem (por ejemplo, errorCode = ACCESS_DENIED), hazlo con cuidado. Un buen compromiso es un helper pequeño que añade una comprobación legible, pero no convierte la prueba en una «caja negra». Por ejemplo, un método expectAccessDenied(...) que verifique 403 y errorCode, pero que se llame directamente en la prueba y no oculte el contexto.

6. Errores típicos al organizar pruebas de seguridad

Error nº1: clase base «mágica» para todas las pruebas de seguridad.
A veces se quiere crear un AbstractSecurityTest que configure todo: usuarios, datos, métodos helper, aserciones comunes, y además una «cómoda» magia sobre MockMvc. El problema es que al cabo de unos días obtienes no un soporte, sino una segunda aplicación dentro de las pruebas. Cuando una prueba falla, primero depuras la clase base, luego la prueba. Para nivel Junior y para un proyecto de aprendizaje, esto casi siempre es complejidad innecesaria. Mejor usar utilidades pequeñas y helpers locales dentro de la clase de prueba concreta.

Error nº2: helper tipo loginAsDefaultUser() sin rol ni username.
Un helper así ahorra dos líneas, pero rompe el significado. En una prueba de seguridad es importante ver explícitamente el actor. Si el método oculta quién está «logueado», la prueba deja de ser documentación de la matriz de acceso y se convierte en una adivinanza. Como resultado, lees la prueba y piensas: «Bueno, ¿y quién está haciendo la solicitud aquí?».

Error nº3: prueba basada en propietario sin fixture del propietario.
Si verificas «bob no puede leer el artículo de alice», pero el artículo no se ha creado, o se ha creado sin autor, o el autor no coincide con el username, entonces la prueba o fallará con 404, o pasará aleatoriamente. En ambos casos pierdes el sentido: la prueba no demuestra la regla de owner-based. Un escenario owner-based debe basarse en datos claros: el recurso existe y pertenece a otro usuario.

Error nº4: mezclar razones de denegación en una misma prueba.
Cuando en una misma prueba verificas acceso (security), transición de estado de negocio (workflow) y formato de error, la prueba se convierte en «todo a la vez». Se vuelve frágil: cambias una regla de negocio — falla la prueba de seguridad, y empiezas a dudar qué se rompió. Es mejor mantener la prueba de seguridad como una verificación de «dejó pasar/no dejó pasar», y la lógica de negocio en sus propias capas (unit/integration), como ya hemos hecho antes.

Error nº5: copia y pega de cadenas de roles y URL por todo el suite.
Hoy escribiste "EDITOR" en diez lugares — y todo está verde. Mañana renombras el rol, o aparece uno nuevo, o cambia la URL, y tienes que buscar los literales de cadena por todo el proyecto. No es una «catástrofe», pero es pérdida de tiempo y nervios. Los fixtures de usuario y las constantes de URL resuelven el problema de forma muy barata, y por eso se amortizan casi de inmediato.

1
Tarea
Spring Test, nivel 23, lección 4
Bloqueada
User fixtures sin copia y pega en pruebas de seguridad HTTP
User fixtures sin copia y pega en pruebas de seguridad HTTP
1
Tarea
Spring Test, nivel 23, lección 4
Bloqueada
Agrupación de pruebas de seguridad por actor mediante `@Nested`
Agrupación de pruebas de seguridad por actor mediante `@Nested`
1
Cuestionario/control
Verificación de acceso, nivel 23, lección 4
No disponible
Verificación de acceso
Testing de Spring Security
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION