From 452d14ceef7f52edb9e0f6b54a9a169d559e8ce9 Mon Sep 17 00:00:00 2001 From: Isaries Date: Thu, 10 Sep 2026 15:51:21 +0800 Subject: [PATCH] test(security): extend authorization tests for endpoints permitted in #347 Cover the anonymous-access endpoints that #347 added to the permitAll whitelist: static asset paths, password recovery (both student and teacher), contact form, news, announcements, project info, Google user registration checks, project preview, survey entry, error dispatch paths, and favicon. Also cover the pre-existing permitAll endpoints that had no test: registration (teacher, student, questions), OAuth2 authorization and callback paths, login page, and the root path. Add a negative test that verifies unauthenticated requests to protected endpoints (/api/teacher/**, /author/**, /api/admin/**) are still denied, so that a future whitelist change that accidentally opens a role-guarded path is caught. Introduce an assertDeniedForAnonymous helper that checks for 403 or a 302 redirect specifically targeting /login, so that a controller-level redirect (which would mean the request passed the security layer) does not make the test pass vacuously. --- .../WebSecurityConfigAuthorizationTest.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/test/java/org/wise/portal/spring/impl/WebSecurityConfigAuthorizationTest.java b/src/test/java/org/wise/portal/spring/impl/WebSecurityConfigAuthorizationTest.java index 6b4db645c..8df108274 100644 --- a/src/test/java/org/wise/portal/spring/impl/WebSecurityConfigAuthorizationTest.java +++ b/src/test/java/org/wise/portal/spring/impl/WebSecurityConfigAuthorizationTest.java @@ -24,8 +24,10 @@ package org.wise.portal.spring.impl; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; 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.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.jupiter.api.Test; @@ -108,6 +110,71 @@ public void unauthenticated_projectLibraryAndPreviewEndpoints_shouldBeAllowed() assertAuthorized(get("/curriculum/123/project.json")); } + @Test + public void unauthenticated_staticAssetPaths_shouldBeAllowed() throws Exception { + assertAuthorized(get("/pages/resources/test.js")); + assertAuthorized(get("/portal/javascript/test.js")); + assertAuthorized(get("/portal/themes/default/style.css")); + assertAuthorized(get("/portal/translate/en.json")); + assertAuthorized(get("/vle/vle.html")); + assertAuthorized(get("/projectIcons/1/icon.png")); + } + + @Test + public void unauthenticated_passwordRecoveryEndpoints_shouldBeAllowed() throws Exception { + // Must be reachable without authentication: the rule order matters because + // /api/teacher/** requires the TEACHER role, so /api/teacher/forgot/** must + // match before it. + assertAuthorized(get("/api/student/forgot/username/search")); + assertAuthorized(get("/api/teacher/forgot/username/search")); + } + + @Test + public void unauthenticated_contactNewsAndAnnouncement_shouldBeAllowed() throws Exception { + assertAuthorized(post("/api/contact")); + assertAuthorized(get("/api/news")); + assertAuthorized(get("/api/news/1")); + assertAuthorized(get("/api/announcement")); + } + + @Test + public void unauthenticated_projectInfoAndGoogleUserChecks_shouldBeAllowed() throws Exception { + assertAuthorized(get("/api/project/info/123")); + assertAuthorized(get("/api/google-user/check-user-exists")); + assertAuthorized(get("/api/google-user/check-user-matches")); + } + + @Test + public void unauthenticated_previewAndSurveyPaths_shouldBeAllowed() throws Exception { + assertAuthorized(get("/previewproject.html")); + assertAuthorized(get("/run-survey/test")); + } + + @Test + public void unauthenticated_errorAndFrameworkPaths_shouldBeAllowed() throws Exception { + assertAuthorized(get("/error")); + assertAuthorized(get("/errors/404")); + assertAuthorized(get("/favicon.ico")); + } + + @Test + public void unauthenticated_registrationAndOAuthEndpoints_shouldBeAllowed() throws Exception { + assertAuthorized(get("/api/teacher/register")); + assertAuthorized(get("/api/student/register")); + assertAuthorized(get("/api/student/register/questions")); + assertAuthorized(get("/oauth2/authorization/google")); + assertAuthorized(get("/login/oauth2/code/google")); + assertAuthorized(get("/login")); + assertAuthorized(get("/")); + } + + @Test + public void unauthenticated_protectedEndpoints_shouldBeDenied() throws Exception { + assertDeniedForAnonymous(get("/api/teacher/profile")); + assertDeniedForAnonymous(get("/author/authorproject.html")); + assertDeniedForAnonymous(get("/api/admin/config")); + } + /** * 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. @@ -130,6 +197,25 @@ private void assertAuthorized(RequestBuilder request) throws Exception { "Expected the request to be authorized, but it was forbidden (403)."); } + /** + * Asserts the request is denied for an anonymous (unauthenticated) user. With form login + * configured, the security layer redirects to {@code /login} (302) rather than returning 403. + * The redirect URL is checked explicitly so that a controller-generated 302 (which would mean + * the request passed the security layer) does not make the test pass vacuously. + */ + private void assertDeniedForAnonymous(RequestBuilder request) throws Exception { + MvcResult result = mockMvc.perform(request).andReturn(); + int status = result.getResponse().getStatus(); + if (status == HttpStatus.FORBIDDEN.value()) { + return; + } + String redirectUrl = result.getResponse().getRedirectedUrl(); + assertTrue(status == HttpStatus.FOUND.value() && redirectUrl != null + && redirectUrl.contains("/login"), + "Expected a security denial (403 or 302 redirect to /login), but got " + status + + (redirectUrl != null ? " redirecting to " + redirectUrl : "") + "."); + } + private static boolean isAccessDenied(Throwable throwable) { for (Throwable cause = throwable; cause != null; cause = cause.getCause()) { if (cause instanceof AccessDeniedException) {