Tests mit Spring Security

Dies ist das siebte Kapitel der Tutorial-Beitragsreihe zu Spring Security. Dieser Beitrag führt die Entwicklung von Tests im Hinblick auf Spring Security vor. Die dabei gezeigten und weitere Testfunktionalitäten von Spring Security können hier und hier nachgeschlagen werden. Den Source Code zu diesem Tutorial findet ihr auf dem Micromata Github Bereich.


Einen Überblick über das gesamte Tutorial, bereits veröffentlichte Kapitel sowie den Ausblick auf kommende Kapitel ist hier zu finden. Falls ihr Fragen zum Tutorial oder Source Code habt, meldet euch einfach über das LABS Kontaktformular oder wendet euch direkt über den Micromata Github Bereich an mich, Jürgen Fast (Micromata).

Vorbereitung

Als Basis dient das Projekt aus dem Beitrag Rollen und Zugriffsrechte mit Spring Security. Hier werden nun zwei weitere Dependencies hinzugefügt:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <version>4.1.3.RELEASE</version>
</dependency>

Grundgerüst der Backend-Tests

Das Grundgerüst der Backend-Tests sieht wie folgt aus:

package de.micromata.spring.security.example.tests;

import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest()
public class ExampleTest {
}

Die Annotation @RunWith(SpringRunner.class) ist ein Alias zu der Annotation @RunWith(SpringJUnit4ClassRunner.class). Sie erweitert die Unit-Klassen um das Spring-TestContext-Framework. Die Annotation @SpringBootTest() fügt diesem Framework noch weitere Funktionen hinzu und ermöglicht uns beispielsweise den Einsatz von @Autowired in unseren Tests.

Annotationen zum Mocken der User

@Test
@WithMockUser
public void exampleTest() {
}

@WithMockUser ist eine Annotation, um einen eingeloggten User zu simulieren. Dabei kann die Annotation auch über der Testklasse direkt platziert werden, wodurch alle Tests mit einem simulierten User ausgeführt werden. Um dies wiederum zu umgehen und innerhalb solch einer Testklasse einen Test ohne simulierten User zu erstellen, gibt es die Annotation @WithAnonymousUser.

@WithMockUser funktioniert jedoch nur, solange UserDetails nicht angepasst wurde, was wir jedoch in dem Kapitel JPA und Spring Security gemacht haben, weshalb bei Nutzung der Annotation folgende Exception auftritt:

org.springframework.security.core.userdetails.User 
cannot be cast to de.micromata.spring.security.example.data.User

Für diesen Fall ist die Annotation @WithUserDetails vorgesehen. Der Einsatz dieser Annotation ist im folgenden Codebeispiel zu sehen.

@RunWith(SpringRunner.class)
@SpringBootTest()
public class MessageTest {

    @Autowired
    MessageRepository messageRepository;

    @Autowired
    UserRepository userRepository;

    @Test(expected = AuthenticationCredentialsNotFoundException.class)
    public void getMessageWithOutUser() {
        messageRepository.findById(110l);
    }

    @Test
    @WithUserDetails(value = "tom")
    public void getMessage() {
        messageRepository.findById(110l);
        messageRepository.findOne(110l);
    }

    @Test
    @WithUserDetails(value = "tom")
    public void getMessageFromAnOtherUser() {
        try {
            messageRepository.findById(100l);
            assertTrue("Tom can see the message of max by findById", false);
        } catch (AccessDeniedException e) {
        }
        try {
            messageRepository.findOne(100l);
            assertTrue("Tom can see the message of max by findOne", false);
        } catch (AccessDeniedException e) {
        }
    }

    @Test
    @WithUserDetails(value = "admin")
    public void getMessageFromAnOtherUserAsAdmin() {
        try {
            messageRepository.findById(110l);
        } catch (AccessDeniedException e) {
            assertTrue("The admin should see the message of tom by findById", false);
        }
        try {
            messageRepository.findOne(110l);
            assertTrue("The admin can see the message of max by findOne", false);
        } catch (AccessDeniedException e) {
        }
    }
}

Der erste Test erwartet eine Exception beim Suchen von Nachrichten, da kein User simuliert wird, während die anderen Tests die im vorherigen Kapitel Rollen und Zugriffsrechte mit Spring Security eingeführten Berechtigungen testen. Im Gegensatz zur Annoation WithMockUser muss der mit WithUserDetails angegebene User auch existieren. Wird kein Wert für value angegeben, so wird per Default user benutzt. Da solch ein User in unserer Applikation nicht existiert, setzen wir den Wert auf tom bzw. admin.

Benutzerspezifische Annotation zum Simulieren von Usern

Damit der Kontext der Applikation geändert werden kann, ohne dabei beispielsweise das Script data.sql anpassen zu müssen, kann eine eigene Annotation erstellt werden, die genau dieses Verhalten bewirkt. Die Annotation selbst ist dabei sehr überschaubar.

package de.micromata.spring.security.example.utils;

import org.springframework.security.test.context.support.WithSecurityContext;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithCustomMockUserSecurityContextFactory.class)
public @interface WithCustomMockUser {

    String username() default "newUser";

}

Der wichtigste Punkt ist die Annotaion WithSecurityContext. Darüber geben wir unsere eigene Implementierung von WithSecurityContextFactory an.

package de.micromata.spring.security.example.utils;

import de.micromata.spring.security.example.data.Message;
import de.micromata.spring.security.example.data.MessageRepository;
import de.micromata.spring.security.example.data.User;
import de.micromata.spring.security.example.data.UserRepository;
import de.micromata.spring.security.example.security.user.AuthenticatedUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.test.context.support.WithSecurityContextFactory;

public class WithCustomMockUserSecurityContextFactory
    implements WithSecurityContextFactory<WithCustomMockUser> {

    @Autowired
    private AuthenticatedUserService authenticatedUserService;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private MessageRepository messageRepository;

    @Override
    public SecurityContext createSecurityContext(WithCustomMockUser customUser) {
        String username = customUser.username();
        createCustomValues(username);
        return authenticate(username);
    }

    public void createCustomValues(String username) {
        User user = userRepository.save(new User(4, username, "password", "ROLE_USER"));
        Message message = new Message();
        message.setTitle("custom");
        message.setText("custom");
        message.setUser(user);
        messageRepository.save(message);
    }

    public SecurityContext authenticate(String username) {
        UserDetails principal = authenticatedUserService.loadUserByUsername(username);
        Authentication authentication = new UsernamePasswordAuthenticationToken(principal, principal.getPassword(),
            principal.getAuthorities());
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authentication);
        return context;
    }
}

Als generischer Typ wir die zuvor gezeigte Annotation übergeben. In der Funktion createSecurityContext kann der Applikationskontext beliebig angepasst werden. In unserem Fall legen wir den über das Attribut username aus der Annotation WithCustomMockUser angegebenen User an. Danach wird für diesen User eine Nachricht angelegt und abschließend eine Authentifizierung durchgeführt. Nun kann die Annotation in den Tests verwendet werden:

@Test
@WithCustomMockUser()
public void getMessageWithCustomMockUser() {
    User user = userRepository.findByUsername("newUser");
    assertTrue("user is null", user != null);

    Iterable<Message> messages = messageRepository.findByUserId(user.getId());
    assertTrue("newUser has no messages", messages != null && messages.iterator().hasNext());
}

In diesesem Test wird lediglich geprüft, dass der User newUser existiert und auch mindestens eine Nachricht besitzt.

Grundgerüst der Controller Tests

Um die Endpunkte der Applikation zu testen, wird zunächst folgendes Grundgerüst angelegt:

package de.micromata.spring.security.example.tests;

import org.junit.Before;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;

@RunWith(SpringRunner.class)
@SpringBootTest()
public class ControllerTest {

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Before
    public void setup() {
        mvc = MockMvcBuilders
            .webAppContextSetup(context)
            .apply(springSecurity())
            .build();
    }
}

Die Annotationen @RunWith(SpringRunner.class)und @SpringBootTest() haben wir bereits kennengelernt. Neu hinzugekommen sind an dieser Stelle der über @Autowired eingebundene WebApplicationContext sowie Springs MockMvc, welches vor jedem Test neu initialisiert wird.

Controller Testbeispiele

Um den Einsatz von MockMvc vorzustellen, folgen nun einige Codebeispiele:

@Test
public void getIndexWithoutLogin() throws Exception{
    ResultActions action = mvc.perform(get("/"));
    int status = action.andReturn().getResponse().getStatus();
    assertTrue("expected status code = 302 ; current status code = " + status, status == 302);
    String redirectURL = action.andReturn().getResponse().getHeader("Location");
    assertTrue("no redirect to the login page", "http://localhost/login".equals(redirectURL));
}

@Test
public void getIndexWithLogin() throws Exception{
    ResultActions action = mvc.perform(get("/").with(user("user")));
    int status = action.andReturn().getResponse().getStatus();
    assertTrue("expected status code = 200 ; current status code = " + status, status == 200);
}

Der erste Test führt einen GET Request gegen den Index-Endpunkt aus. Das erwartete verhalten ist, dass ein Redirect zur Login-Seite vollzogen wird, da kein Login stattgefunden hat. Im zweiten Test wird dies mit einem eingeloggten User probiert, weshalb hier auch der erfolgreiche Statuscode 200 erwartet wird. Der User braucht dabei nicht zu existieren.

@Test
public void getConsoleWithLoginUser() throws Exception{
    ResultActions action = mvc.perform(get("/console").with(user("user")));
    int status = action.andReturn().getResponse().getStatus();
    assertTrue("expected status code = 403 ; current status code = " + status, status == 403);
}

@Test
public void getConsoleWithLoginAdmin() throws Exception{
    ResultActions action = mvc.perform(post("/console").with(user("admin").roles("ADMIN")));
    int status = action.andReturn().getResponse().getStatus();
    assertTrue("expected status code = 404 ; current status code = " + status, status == 404);
}

@Test
public void getNoSecurity() throws Exception{
    ResultActions action = mvc.perform(get("/noSecurity"));
    int status = action.andReturn().getResponse().getStatus();
    assertTrue("expected status code = 200 ; current status code = " + status, status == 200);
}

In diesen Tests werden weitere Endpunkte getestet und der Einsatz von roles vorgeführt.

@Test
public void checkCsrfWithToken() throws Exception{
    ResultActions action = mvc.perform(post("/noSecurity").with(csrf()).with(user("user")));
    int status = action.andReturn().getResponse().getStatus();
    assertTrue("expected status code = 200 ; current status code = " + status, status == 200);
}

@Test
public void checkCsrfWithOutToken() throws Exception{
    ResultActions action = mvc.perform(post("/noSecurity").with(user("user")));
    int status = action.andReturn().getResponse().getStatus();
    assertTrue("expected status code = 403 ; current status code = " + status, status == 403);
}

Hier wird der CSRF-Schutz unserer Applikation getestet.

@Test
public void checkLogin() throws Exception{
    ResultActions action = mvc.perform(formLogin("/login").user("admin").password("password"));
    int status = action.andReturn().getResponse().getStatus();
    assertTrue("expected status code = 302 ; current status code = " + status, status == 302);
    String redirectURL = action.andReturn().getResponse().getHeader("Location");
    assertTrue("login with valid user and password not possible", "/".equals(redirectURL));
}

@Test
public void checkLoginWithWrongPassword() throws Exception{
    ResultActions action = mvc.perform(formLogin("/login").user("admin").password("pass"));
    int status = action.andReturn().getResponse().getStatus();
    assertTrue("expected status code = 302 ; current status code = " + status, status == 302);
    String redirectURL = action.andReturn().getResponse().getHeader("Location");
    assertTrue("possible login with wrong password", "/login?error".equals(redirectURL));
}

Abschließend sind an dieser Stelle noch zwei Tests zum Login zu sehen.

Autor

Jürgen Fast arbeitet seit 2014 bei der Micromata GmbH als Softwareentwickler in verschiedenen Projekten der Logistik- und Automobilbranche. Sein Schwerpunkt liegt in der Entwicklung von javabasierten Webapplikationen.