spring-projects / spring-session

Spring Session
https://spring.io/projects/spring-session
Apache License 2.0
1.86k stars 1.11k forks source link

Improve MockMvc testability #1019

Open LouisVN opened 6 years ago

LouisVN commented 6 years ago

Summary

SessionRepositoryFilter wraps the incoming HttpServletRequest and alters the expected SecurityContext retrieval when performing MockMvc requests.

Configuration

Once setting up Spring Security + Spring Session + Spring Session JDBC, I had to force the SessionRepositoryFilter to kick in before Spring Security's filter chain as follow :

public class WebSecurity extends WebSecurityConfigurerAdapter {

    @Autowired
    private SessionRepositoryFilter springSessionRepositoryFilter;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(springSessionRepositoryFilter, ChannelProcessingFilter.class)
        ...
    }
}

Problem

Tests written with MockMvc cannot leverage from Spring Security's authentication validation and requires 'cumbersome' reflection to get through authentication details.Tests written with MockMvc

The SecurityMockMvcResultMatchers and/or WebTestUtils are able to retrieve the HttpSessionSecurityContextRepository from the MvcResult. However, the MockHttpServletRequest contains a null MockHttpSession which is what HttpSessionSecurityContextRepository.loadContext is 'ultimately' checking.

Therefore, it will return null as well instead of the valid SecurityContext which is meant - in such setup - to be found through the HttpSession (attached as a request attribute - key = "org.springframework.session.SessionRepository.CURRENT_SESSION").

Here is an example of problematic test :

    @Test
    public void login() throws Exception {
        User user = createUser();
        MockMvcBuilders.webAppContextSetup(wac)
            .apply(springSecurity())
            .build()
            .perform(
                post("/login")
                    .param("email", user.getEmail())
                    .param("password", "somepassword"))
            // FAILURE BELOW
            .andExpect(authenticated().withUsername(user.getUsername()));
    }

Workaround

All classes are closed from extension with private or final. It makes sense but makes testing difficult. In order to retrieve a valid SecurityContext with authentication, I used the following 'trick' :

    @Test
    public void login() throws Exception {
        User user = createUser();
        MockMvcBuilders.webAppContextSetup(wac)
            .apply(springSecurity())
            .build()
            .perform(
                post("/login")
                    .param("email", user.getEmail())
                    .param("password", "somepassword"))
            .andExpect(matchAuthenticationPrincipal(principal ->
                assertThat(principal.getId(), equalTo(user.getId()))
            ));
    }

        ...

    protected User getAuthenticatedPrincipalUserFromResult(MvcResult result) {
        HttpSession wrappedSession = getWrappedSessionFromAttributes(result.getRequest());
        SecurityContext securityContext = getSecurityContextFromSession(wrappedSession);
        assertThat(securityContext, notNullValue());
        assertThat(securityContext.getAuthentication(), notNullValue());
        assertThat(securityContext.getAuthentication().getPrincipal(), instanceOf(User.class));
        return (User) securityContext.getAuthentication().getPrincipal();
    }

    protected HttpSession getWrappedSessionFromAttributes(MockHttpServletRequest request) {
        Object sessionRepositoryFilter_sessionRepositoryRequestWrapper = request.getAttribute(
            "org.springframework.session.SessionRepository.CURRENT_SESSION");
        notNull(
            sessionRepositoryFilter_sessionRepositoryRequestWrapper,
            "The request should contain an attribute wrapping the request including the session");
        isInstanceOf(HttpSession.class, sessionRepositoryFilter_sessionRepositoryRequestWrapper);
        return (HttpSession) sessionRepositoryFilter_sessionRepositoryRequestWrapper;
    }

    protected SecurityContext getSecurityContextFromSession(HttpSession wrappedSession) {
        Object jdbcOperationsSessionRepository_jdbcSession = ReflectionTestUtils.getField(
            wrappedSession,
            "session");
        notNull(
            jdbcOperationsSessionRepository_jdbcSession,
            "The wrapped request should contain an instance of a JdbcSession");

        Object mapSession = ReflectionTestUtils.getField(
            jdbcOperationsSessionRepository_jdbcSession,
            "delegate");
        notNull(
            mapSession,
            "The session should contain an instance of MapSession containing the session");

        Object sessionAttributes = ReflectionTestUtils.getField(
            mapSession,
            "sessionAttrs");
        notNull(
            sessionAttributes,
            "The mapper should contain session attributes");
        isInstanceOf(Map.class, sessionAttributes);

        Map<String, Object> attributesMap = (Map<String, Object>) sessionAttributes;
        assertThat(attributesMap, hasKey(equalTo(SPRING_SECURITY_CONTEXT_KEY)));

        Object securityContext = attributesMap.get(SPRING_SECURITY_CONTEXT_KEY);
        isInstanceOf(SecurityContext.class, securityContext);
        return (SecurityContext) securityContext;
    }

Ideally expected

  1. Convenience calls to retrieve the real (previously wrapped) HttpSession or the SecurityContext.
  2. Some tests within Spring Session's baseline validating proper HttpSession creation in a similar setup 🤔 ?

Disclaimer : I shortened the configuration/setup to only highlights the key-points. Please let me know if you need a more comprehensive example or if the idea is explicit enough 😃

fberlakovich commented 4 years ago

I can confirm this issue. As described by @LouisVN , the spring-security MockMVC test methods operate directly on the MockMVC MockRequest instead of the SessionRepositoryRequestWrapper. Therefore the test methods (e.g. authenticated()) cannot see the session created through spring-session.

mhagnumdw commented 3 years ago

Is my scenario below the same issue?

My application works normally, only tests fail. I'm using Spring Session JDBC with H2 in memory.

My tests:

@AutoConfigureMockMvc
@TestInstance(Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.Alphanumeric.class)
@TestPropertySource("/application-test.properties")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
public class LoginControllerIT extends IdpApplicationTest {

    @Test
    public void test010_DoesNotLogIn_UserNotFound() throws Exception {
        MvcResult result = mockMvc.perform(post(LOGIN_URL)
                .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE)
                .param("username", "22222222222"))
            .andExpect(status().is3xxRedirection())
            .andReturn();

        HttpSession session = result.getRequest().getSession(false);

        // PROBLEM HERE: WebAttributes.AUTHENTICATION_EXCEPTION attribute
        // is null on session, but my AuthenticationFailureHandler is filling it
        AuthenticationException exception = (AuthenticationException) session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);

        assertEquals("Invalid username or password.", exception.getMessage());
    }

}

The session is serialized on the BD.