diff --git a/pom.xml b/pom.xml index ca50c647b..d267d41a4 100644 --- a/pom.xml +++ b/pom.xml @@ -390,6 +390,11 @@ spring-boot-starter-test test + + org.springframework.security + spring-security-test + test + org.springframework.boot spring-boot-devtools diff --git a/src/test/java/org/wise/portal/spring/impl/WebSecurityConfigAuthorizationTest.java b/src/test/java/org/wise/portal/spring/impl/WebSecurityConfigAuthorizationTest.java new file mode 100644 index 000000000..b1a4eadff --- /dev/null +++ b/src/test/java/org/wise/portal/spring/impl/WebSecurityConfigAuthorizationTest.java @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2008-2019 Regents of the University of California (Regents). + * Created by WISE, Graduate School of Education, University of California, Berkeley. + * + * This software is distributed under the GNU General Public License, v3, + * or (at your option) any later version. + * + * Permission is hereby granted, without written agreement and without license + * or royalty fees, to use, copy, modify, and distribute this software and its + * documentation for any purpose, provided that the above copyright notice and + * the following two paragraphs appear in all copies of this software. + * + * REGENTS SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE. THE SOFTWARE AND ACCOMPANYING DOCUMENTATION, IF ANY, PROVIDED + * HEREUNDER IS PROVIDED "AS IS". REGENTS HAS NO OBLIGATION TO PROVIDE + * MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. + * + * IN NO EVENT SHALL REGENTS BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, + * SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, + * ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF + * REGENTS HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.wise.portal.spring.impl; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +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; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.RequestBuilder; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** + * Exercises the administrator-only boundary around user and account management through the real + * Spring Security filter chain, so that it cannot regress. The boundary is enforced by two layers + * that both run in the chain: the {@link WebSecurityConfig} URL rules and the controllers' + * {@code @Secured} method security. Where both layers guard a path these tests assert the + * effective decision (is the request denied?) rather than isolating a single layer. Controller + * unit tests instantiate the controller directly and never go through the security proxy, so they + * cannot cover this. + * + * The whole web context is started because the authorization rules are only wired up as part of + * it. That context connects to Redis on startup (session repository and a message listener), so a + * Redis container is provided the same way the data store integration tests do. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +public class WebSecurityConfigAuthorizationTest { + + @Container + static GenericContainer redisContainer = + new GenericContainer<>(DockerImageName.parse("redis:7-alpine")).withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.redis.host", redisContainer::getHost); + registry.add("spring.redis.port", redisContainer::getFirstMappedPort); + } + + @Autowired + private MockMvc mockMvc; + + @Test + public void researcher_userAndAccountManagement_shouldBeForbidden() throws Exception { + mockMvc.perform(get("/admin/account/show-all-users").with(user("researcher").roles("RESEARCHER"))) + .andExpect(status().isForbidden()); + } + + @Test + public void researcher_trailingSlashOnExactAdminPath_shouldBeForbidden() throws Exception { + // The trailing-slash form of an exact administrator path must stay denied to researchers. + // Both the mvcMatchers URL rule and AdminUtilsController's @Secured enforce this, so a pass + // confirms the endpoint is denied, not specifically which layer denied it. + mockMvc.perform(get("/admin/mergeProjectMetadata/").with(user("researcher").roles("RESEARCHER"))) + .andExpect(status().isForbidden()); + } + + @Test + public void administrator_userAndAccountManagement_shouldBeAuthorized() throws Exception { + // Positive counterpart to researcher_userAndAccountManagement_shouldBeForbidden on the same + // path: the rule restricts by role rather than blocking the path outright. + assertAuthorized(get("/admin/account/show-all-users").with(user("admin").roles("ADMINISTRATOR"))); + } + + @Test + public void researcher_nonAccountAdminEndpoint_shouldStayAuthorized() throws Exception { + // Researchers keep access to the rest of /admin/**. RunStatisticsController has no method + // security, so this exercises the URL rule directly rather than a method-security backstop. + assertAuthorized(get("/admin/run/stats").with(user("researcher").roles("RESEARCHER"))); + } + + /** + * Asserts the request passes authorization. An authorization denial short-circuits with a 403 + * response before the handler runs, so reaching the handler means the request was authorized. + * The handler may then return any status or fail, because the test authenticates with a mock + * principal rather than a persisted WISE user; such a downstream failure still counts as + * authorized. A propagated {@link AccessDeniedException} is the one exception that does mean a + * denial, so it is re-thrown rather than swallowed. + */ + private void assertAuthorized(RequestBuilder request) throws Exception { + MvcResult result; + try { + result = mockMvc.perform(request).andReturn(); + } catch (Exception e) { + if (isAccessDenied(e)) { + throw e; + } + return; + } + assertNotEquals(HttpStatus.FORBIDDEN.value(), result.getResponse().getStatus(), + "Expected the request to be authorized, but it was forbidden (403)."); + } + + private static boolean isAccessDenied(Throwable throwable) { + for (Throwable cause = throwable; cause != null; cause = cause.getCause()) { + if (cause instanceof AccessDeniedException) { + return true; + } + } + return false; + } +}