diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/MeControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/MeControllerTest.java index b967ab5ef91e..379e7e2ea920 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/MeControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/MeControllerTest.java @@ -37,6 +37,7 @@ import static org.hisp.dhis.security.apikey.ApiKeyTokenGenerator.generatePersonalAccessToken; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -136,6 +137,54 @@ void testHasAuthority() { assertFalse(GET("/me/authorities/missing").content(HttpStatus.OK).booleanValue()); } + /** + * Contract pins for the {@code /api/me} response shape. These assert the CURRENT contract that + * clients rely on; changing any of them must be a deliberate, reviewed decision. + */ + @Test + void testGetCurrentUser_ContractShape() { + JsonObject me = GET("/me").content(HttpStatus.OK); + + assertEquals("userA", me.getString("username").string()); + // PIN: "name" is the display name (first name + surname), NOT the username + assertEquals(userA.getFirstName() + " " + userA.getSurname(), me.getString("name").string()); + assertNotEquals(me.getString("username").string(), me.getString("name").string()); + assertTrue(me.getArray("authorities").stringValues().contains("ALL")); + assertTrue(me.getObject("settings").exists()); + // impersonation fields only appear while impersonating / when the feature is enabled + assertFalse(me.get("impersonation").exists()); + assertFalse(me.get("canImpersonate").exists()); + } + + @Test + void testGetAuthorities_ExactSetForLimitedUser() { + switchToNewUser("contract", "F_PERFORM_MAINTENANCE"); + assertEquals( + singletonList("F_PERFORM_MAINTENANCE"), + GET("/me/authorities").content(HttpStatus.OK).stringValues()); + assertEquals( + singletonList("F_PERFORM_MAINTENANCE"), + GET("/me?fields=authorities") + .content(HttpStatus.OK) + .getArray("authorities") + .stringValues()); + } + + @Test + void testHasAuthority_NonSuperuserExactMatch() { + switchToNewUser("contract", "F_PERFORM_MAINTENANCE"); + assertTrue( + GET("/me/authorization/F_PERFORM_MAINTENANCE").content(HttpStatus.OK).booleanValue()); + assertFalse(GET("/me/authorization/F_DOES_NOT_EXIST").content(HttpStatus.OK).booleanValue()); + } + + @Test + void testHasAuthority_SuperuserImplicitlyHasAnyAuthority() { + // PIN: userA holds ALL; isAuthorized() implicitly grants ANY authority for superusers + assertTrue( + GET("/me/authorization/F_PERFORM_MAINTENANCE").content(HttpStatus.OK).booleanValue()); + } + @Test void testGetEmailVerifiedProperty() { assertFalse(GET("/me").content().as(JsonMeDto.class).getEmailVerified()); diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/security/ImpersonateUserControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/security/ImpersonateUserControllerTest.java index a92483e24112..f3ad261c0e7b 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/security/ImpersonateUserControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/security/ImpersonateUserControllerTest.java @@ -30,11 +30,15 @@ package org.hisp.dhis.webapi.controller.security; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Properties; import org.hisp.dhis.external.conf.ConfigurationKey; import org.hisp.dhis.external.conf.DhisConfigurationProvider; import org.hisp.dhis.http.HttpStatus; +import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.test.config.H2DhisConfigurationProvider; import org.hisp.dhis.test.webapi.H2ControllerIntegrationTestBase; import org.hisp.dhis.test.webapi.json.domain.JsonImpersonateUserResponse; @@ -209,4 +213,51 @@ void testImpersonateWithAuthorityNotAllowedToBecomeSuperuser() { assertEquals("Forbidden", response.getHttpStatus()); assertEquals("ERROR", response.getStatus()); } + + /** + * PIN: the {@code /api/me} {@code impersonation} field carries the impersonator USERNAME (from + * {@code Authentication#getName()}), never the display name. Frontends match it against the + * username; PR #24124 accidentally changed it to first+surname which this test would catch. + */ + @Test + void testMeImpersonationFieldsDuringAndAfterImpersonation() { + User impersonator = createUserWithAuth("impuser", "F_IMPERSONATE_USER"); + createUserWithAuth("target", "NONE"); + injectSecurityContextUser(impersonator); + + POST("/auth/impersonate?username=target").content(HttpStatus.OK); + + JsonObject during = + GET("/me?fields=username,impersonation,canImpersonate").content(HttpStatus.OK); + assertEquals("target", during.getString("username").string()); + assertEquals("impuser", during.getString("impersonation").string()); + assertNotEquals(impersonator.getName(), during.getString("impersonation").string()); + // the impersonated user itself holds no impersonation authority + assertFalse(during.get("canImpersonate").exists()); + + POST("/auth/impersonateExit").content(HttpStatus.OK); + + JsonObject after = + GET("/me?fields=username,impersonation,canImpersonate").content(HttpStatus.OK); + assertEquals("impuser", after.getString("username").string()); + assertFalse(after.get("impersonation").exists()); + assertTrue(after.getBoolean("canImpersonate").booleanValue()); + } + + @Test + void testMeCanImpersonateForAdminWhenFeatureEnabled() { + JsonObject me = GET("/me?fields=canImpersonate,impersonation").content(HttpStatus.OK); + assertTrue(me.getBoolean("canImpersonate").booleanValue()); + assertFalse(me.get("impersonation").exists()); + } + + @Test + void testMeCanImpersonateAbsentWithoutAuthority() { + User guest = createUserWithAuth("guestimp", "NONE"); + injectSecurityContextUser(guest); + + JsonObject me = GET("/me?fields=canImpersonate,impersonation").content(HttpStatus.OK); + assertFalse(me.get("canImpersonate").exists()); + assertFalse(me.get("impersonation").exists()); + } } diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/mvc/interceptor/AuthorityInterceptorTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/mvc/interceptor/AuthorityInterceptorTest.java new file mode 100644 index 000000000000..6129fd92c2c9 --- /dev/null +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/mvc/interceptor/AuthorityInterceptorTest.java @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.webapi.mvc.interceptor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import org.hisp.dhis.security.Authorities; +import org.hisp.dhis.security.RequiresAuthority; +import org.hisp.dhis.security.apikey.ApiTokenAuthenticationToken; +import org.hisp.dhis.user.UserDetails; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.session.SessionAuthenticationException; +import org.springframework.security.web.authentication.switchuser.SwitchUserGrantedAuthority; +import org.springframework.web.method.HandlerMethod; + +/** + * Characterization tests for {@link AuthorityInterceptor}. These tests pin the CURRENT contract of + * {@code @RequiresAuthority} enforcement. They are deliberate behavior pins, not endorsements: + * changing any of the pinned behaviors (especially the authority source, see {@link + * #authoritiesAreReadFromTokenNotFromPrincipal()}) must be an explicit, reviewed decision. + * + *
Pinned contract: + * + *