如何对使用 thymeleaf 的安全控制器进行单元测试(不获取 TemplateProcessingException)?

Posted

技术标签:

【中文标题】如何对使用 thymeleaf 的安全控制器进行单元测试(不获取 TemplateProcessingException)?【英文标题】:How to unit test a secured controller which uses thymeleaf (without getting TemplateProcessingException)? 【发布时间】:2014-09-19 21:21:40 【问题描述】:

我正在尝试在 spring-boot 中使用 spring security 和一个使用 thymeleaf 进行模板处理的简单主(根)控制器运行单元测试。我正在尝试编写一些单元测试来验证我的安全权限是否正常工作,并且正确的数据是否从我的模板中隐藏或显示(它使用 thymeleaf spring 安全集成)。当我运行它时,应用程序本身可以正常工作。我只是想验证它是否正在使用一组集成测试。 您可以在这里找到所有代码,但我也会在下面包含相关的 sn-ps:

https://github.com/azeckoski/lti_starter

控制器非常简单,除了渲染模板(在根 - 即“/”)之外什么都不做。

@Controller
public class HomeController extends BaseController 
    @RequestMapping(method = RequestMethod.GET)
    public String index(HttpServletRequest req, Principal principal, Model model) 
        log.info("HOME: " + req);
        model.addAttribute("name", "HOME");
        return "home"; // name of the template
    

模板有很多内容,但测试的相关部分是:

<p>Hello Spring Boot User <span th:text="$username"/>! (<span th:text="$name"/>)</p>
<div sec:authorize="hasRole('ROLE_USER')">
    This content is only shown to users (ROLE_USER).
</div>
<div sec:authorize="isAnonymous()"><!-- only show this when user is NOT logged in -->
    <h2>Form Login endpoint</h2>
    ...
</div>

最后是测试:

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
@WebAppConfiguration
public class AppControllersTest extends BaseApplicationTest 

    @Autowired
    WebApplicationContext wac;

    @Autowired
    private FilterChainProxy springSecurityFilter;

    private MockMvc mockMvc;

    @Before
    public void setup() 
        // Process mock annotations
        MockitoAnnotations.initMocks(this);
        // Setup Spring test in webapp-mode (same config as spring-boot)
        this.mockMvc = MockMvcBuilders.webAppContextSetup(wac)
                .addFilter(springSecurityFilter, "/*")
                .build();
    

    @Test
    public void testLoadRoot() throws Exception 
        // Test basic home controller request
        MvcResult result = this.mockMvc.perform(get("/"))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_html))
                .andReturn();
        String content = result.getResponse().getContentAsString();
        assertNotNull(content);
        assertTrue(content.contains("Hello Spring Boot"));
        assertTrue(content.contains("Form Login endpoint"));
    

    @Test
    public void testLoadRootWithAuth() throws Exception 
        Collection<GrantedAuthority> authorities = new HashSet<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
        Authentication authToken = new UsernamePasswordAuthenticationToken("azeckoski", "password", authorities);
        SecurityContextHolder.getContext().setAuthentication(authToken);
        // Test basic home controller request
        MvcResult result = this.mockMvc.perform(get("/"))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
                .andReturn();
        String content = result.getResponse().getContentAsString();
        assertNotNull(content);
        assertTrue(content.contains("Hello Spring Boot"));
        assertTrue(content.contains("only shown to users (ROLE_USER)"));
    

我在上述两个测试中得到的错误是:

testLoadRoot(ltistarter.controllers.AppControllersTest) 已用时间: 0.648 秒

但是,只有在启用两个测试并且包含 springSecurityFilter 时才会发生这种情况。如果我禁用其中一项测试并删除 springSecurityFilter 代码 (.addFilter(springSecurityFilter, "/*")),那么我将不再收到该错误。我怀疑某些东西可能会弄乱 WebApplicationContext 或使安全性内容处于某种故障状态,但我不确定我需要重置或更改什么。

因此,如果我取出第二个测试并删除 springSecurityFilter,那么我的第一个测试仍然会失败(尤其是这个assertTrue(content.contains("Form Login endpoint"))),但我不再收到任何错误。当我查看生成的 HTML 时,我没有看到任何使用 sec:authorize 属性的标签内容。

所以我四处搜索,发现我需要在springSecurityFilter 中添加一个建议(我已经在上面的代码示例中完成了),但是,一旦我这样做了,我就会立即失败(它没有甚至到了没有它就失败的地步)。有关导致该异常的原因以及如何修复它的任何建议?

【问题讨论】:

消息 处理器执行期间出错 并没有真正的帮助。你能包括完整的堆栈跟踪吗? 给我们你所拥有的一切:-) 第一条有用的行说明了WebApplicationContextUtils:84 中的一个问题,即throw new IllegalStateException("No WebApplicationContext found: no ContextLoaderListener registered?");。我不确定为什么在那个阶段上下文会为空。 我也是。它似乎不是空的(我什至添加了一个检查以确保它不是)。也许我需要做一些事情来使它可以访问它? 添加问题:github.com/thymeleaf/thymeleaf-extras-springsecurity3/issues/15 【参考方案1】:

如果您不关心测试返回的视图并且只想测试控制器,只需在您的测试应用程序属性文件中禁用 Thymeleaf is Spring boot 2+

spring.thymeleaf.enabled=false

【讨论】:

【参考方案2】:

我有一个变通的解决方案,似乎可以完全解决 spring-boot:1.1.4、spring-security:3.2.4 和 thymeleaf:2.1.3 的这个问题(虽然它有点小技巧)。

这是修改后的单元测试类:

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
@WebAppConfiguration
public class AppControllersTest 

    @Autowired
    public WebApplicationContext context;

    @Autowired
    private FilterChainProxy springSecurityFilter;

    private MockMvc mockMvc;

    @Before
    public void setup() 
        assertNotNull(context);
        assertNotNull(springSecurityFilter);
        // Process mock annotations
        MockitoAnnotations.initMocks(this);
        // Setup Spring test in webapp-mode (same config as spring-boot)
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .addFilters(springSecurityFilter)
                .build();
        context.getServletContext().setAttribute(
            WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, context);
    
...

这里的魔力是强制 WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE 成为实际的 Web 应用程序上下文(我注入的)。 这允许实际的 sec: 属性工作但我的第二个测试我尝试设置权限以便用户登录没有通过(看起来用户仍然是匿名的)。

更新

缺少一些东西(我认为这是 spring 安全性工作方式的一个差距),但幸运的是很容易解决(虽然它有点像 hack)。有关此问题的更多详细信息,请参阅此内容:Spring Test & Security: How to mock authentication?

我需要添加一个为测试创建模拟会话的方法。此方法将设置安全Principal/Authentication 并强制SecurityContext 进入HttpSession,然后可以将其添加到测试请求中(参见下面的测试sn-p 和NamedOAuthPrincipal 类示例)。

public MockHttpSession makeAuthSession(String username, String... roles) 
    if (StringUtils.isEmpty(username)) 
        username = "azeckoski";
    
    MockHttpSession session = new MockHttpSession();
    session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext());
    Collection<GrantedAuthority> authorities = new HashSet<>();
    if (roles != null && roles.length > 0) 
        for (String role : roles) 
            authorities.add(new SimpleGrantedAuthority(role));
        
    
    //Authentication authToken = new UsernamePasswordAuthenticationToken("azeckoski", "password", authorities); // causes a NPE when it tries to access the Principal
    Principal principal = new NamedOAuthPrincipal(username, authorities,
            "key", "signature", "HMAC-SHA-1", "signaturebase", "token");
    Authentication authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities);
    SecurityContextHolder.getContext().setAuthentication(authToken);
    return session;

创建Principal 的类(通过 ConsumerCredentials 支持 OAuth)。如果您不使用 OAuth,那么您可以跳过 ConsumerCredentials 部分,只需实现 Principal(但您应该返回 GrantedAuthority 的集合)。

public static class NamedOAuthPrincipal extends ConsumerCredentials implements Principal 
    public String name;
    public Collection<GrantedAuthority> authorities;
    public NamedOAuthPrincipal(String name, Collection<GrantedAuthority> authorities, String consumerKey, String signature, String signatureMethod, String signatureBaseString, String token) 
        super(consumerKey, signature, signatureMethod, signatureBaseString, token);
        this.name = name;
        this.authorities = authorities;
    
    @Override
    public String getName() 
        return name;
    
    public Collection<? extends GrantedAuthority> getAuthorities() 
        return authorities;
    

然后像这样修改测试(创建会话,然后在模拟请求上设置它):

@Test
public void testLoadRootWithAuth() throws Exception 
    // Test basic home controller request with a session and logged in user
    MockHttpSession session = makeAuthSession("azeckoski", "ROLE_USER");
    MvcResult result = this.mockMvc.perform(get("/").session(session))
            .andExpect(status().isOk())
            .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
            .andReturn();
    String content = result.getResponse().getContentAsString();
    assertNotNull(content);
    assertTrue(content.contains("Hello Spring Boot"));

【讨论】:

非常感谢您提供如此出色的答案。奇怪的是,安全过滤器没有与上下文一起加载!

以上是关于如何对使用 thymeleaf 的安全控制器进行单元测试(不获取 TemplateProcessingException)?的主要内容,如果未能解决你的问题,请参考以下文章

使用默认值声明 Thymeleaf 变量后如何对其进行算术运算?

如何使用 Thymeleaf 和 HTML 将数据显示到两列?

「SpringBoot实战」视图技术-Thymeleaf

将分页与 Spring Boot 和 Thymeleaf 一起使用时出错

(Spring,Thymeleaf)如何使用模型内的模型列表向控制器“POST”请求?

加密之SSL和单双向认证