From 1f5c3125b93561d1c7f4a562fd96619d3aa55a4d Mon Sep 17 00:00:00 2001 From: Duane May Date: Mon, 24 Aug 2026 15:42:55 -0400 Subject: [PATCH 1/2] Fix PATCH /Users/{userId}/status ignoring caller's Accept header updateAccountStatus (backing unlock-account and force-password-change) was missing @ResponseBody -- dropped by accident in 6159e2f4 (2016) when this endpoint moved from PUT to PATCH, while every sibling endpoint in this @Controller class kept it. Without @ResponseBody, Spring falls back to view-name resolution instead of the normal HttpMessageConverter path; a client that omits an explicit `Accept: application/json` header (e.g. uaa-cli's unlock-user, which uses a raw PATCH via its curl helper) gets routed to Thymeleaf trying to resolve a nonexistent template, and 500s. With Accept: application/json explicitly sent, a ContentNegotiatingView Resolver JSON fallback view papered over the missing annotation, which is why the existing MockMvc tests (which do set that header) never caught this. Restoring @ResponseBody makes the endpoint correctly return JSON regardless of the caller's Accept header, like every other endpoint here. Side effect: the response Content-Type changes from the JSON view's `application/json;charset=UTF-8` to the standard HttpMessageConverter's `application/json` (no charset param), matching every other endpoint in this file -- the two affected MockMvc test classes are updated to match. Co-Authored-By: Claude Sonnet 5 --- .../identity/uaa/scim/endpoints/ScimUserEndpoints.java | 1 + .../scim/endpoints/ScimUserEndpointsMockMvcTests.java | 9 ++++----- .../endpoints/ScimUserEndpointsMockMvcZonePathTests.java | 9 ++++----- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java index 5755c4f4a68..e277937385c 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java @@ -555,6 +555,7 @@ public SearchResults findUsers( } @PatchMapping("/Users/{userId}/status") + @ResponseBody public UserAccountStatus updateAccountStatus(@RequestBody UserAccountStatus status, @PathVariable String userId) { ScimUser user = scimUserProvisioning.retrieve(userId, identityZoneManager.getCurrentIdentityZoneId()); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java index 07473c027fd..63290b128d2 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java @@ -87,7 +87,6 @@ @ExtendWith(ZoneSeederExtension.class) @DefaultTestContext class ScimUserEndpointsMockMvcTests { - private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", java.nio.charset.StandardCharsets.UTF_8); private static final String HTTP_REDIRECT_EXAMPLE_COM = "http://redirect.example.com"; private static final String USER_PASSWORD = "pas5Word"; private String scimReadWriteToken; @@ -630,7 +629,7 @@ void unlockAccount() throws Exception { alteredAccountStatus.setLocked(false); updateAccountStatus(userToLockout, alteredAccountStatus) .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON_UTF8)) + .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(content().string(JsonUtils.writeValueAsString(alteredAccountStatus))); attemptLogin(userToLockout) @@ -644,7 +643,7 @@ void accountStatusEmptyPatchDoesNotUnlock() throws Exception { updateAccountStatus(userToLockout, new UserAccountStatus()) .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON_UTF8)) + .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(content().string("{}")); attemptLogin(userToLockout) @@ -672,7 +671,7 @@ void unlockAccountWhenNotLocked() throws Exception { alteredAccountStatus.setLocked(false); updateAccountStatus(userToLockout, alteredAccountStatus) .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON_UTF8)) + .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(content().string(JsonUtils.writeValueAsString(alteredAccountStatus))); attemptLogin(userToLockout) @@ -716,7 +715,7 @@ void forcePasswordChange() throws Exception { updateAccountStatus(user, alteredAccountStatus) .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON_UTF8)) + .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(content().string(JsonUtils.writeValueAsString(alteredAccountStatus))); assertThat(usersRepository.checkPasswordChangeIndividuallyRequired(user.getId(), IdentityZoneHolder.get().getId())).isTrue(); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcZonePathTests.java index e59cb1c746e..e558e2406b6 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcZonePathTests.java @@ -96,7 +96,6 @@ @DefaultTestContext @EnabledIfZonePathsEnabled class ScimUserEndpointsMockMvcZonePathTests { - private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", java.nio.charset.StandardCharsets.UTF_8); private static final String HTTP_REDIRECT_EXAMPLE_COM = "http://redirect.example.com"; private static final String USER_PASSWORD = "pas5Word"; private String scimReadWriteToken; @@ -660,7 +659,7 @@ void unlockAccount(ZoneResolutionMode mode) throws Exception { alteredAccountStatus.setLocked(false); updateAccountStatus(userToLockout, alteredAccountStatus) .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON_UTF8)) + .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(content().string(JsonUtils.writeValueAsString(alteredAccountStatus))); attemptLogin(userToLockout) @@ -675,7 +674,7 @@ void accountStatusEmptyPatchDoesNotUnlock(ZoneResolutionMode mode) throws Except updateAccountStatus(userToLockout, new UserAccountStatus()) .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON_UTF8)) + .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(content().string("{}")); attemptLogin(userToLockout) @@ -703,7 +702,7 @@ void unlockAccountWhenNotLocked() throws Exception { alteredAccountStatus.setLocked(false); updateAccountStatus(userToLockout, alteredAccountStatus) .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON_UTF8)) + .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(content().string(JsonUtils.writeValueAsString(alteredAccountStatus))); attemptLogin(userToLockout) @@ -747,7 +746,7 @@ void forcePasswordChange() throws Exception { updateAccountStatus(user, alteredAccountStatus) .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON_UTF8)) + .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(content().string(JsonUtils.writeValueAsString(alteredAccountStatus))); assertThat(usersRepository.checkPasswordChangeIndividuallyRequired(user.getId(), IdentityZoneHolder.get().getId())).isTrue(); From ee04ca067d1467c9f69ad314f72a01eb583a59f1 Mon Sep 17 00:00:00 2001 From: Duane May Date: Mon, 24 Aug 2026 15:59:30 -0400 Subject: [PATCH 2/2] Add regression test for missing-Accept-header account status requests Copilot review feedback on #4053: the existing MockMvc helper always sets Accept: application/json, so it never exercised the view-resolution vs HttpMessageConverter behavior that the missing @ResponseBody bug actually depended on. Add a variant that omits the Accept header and assert 200 + correct JSON, matching how uaa-cli's unlock-user issues this request. Confirmed this test fails without @ResponseBody (reverted it locally, re-ran, saw the failure) and passes with it restored. Co-Authored-By: Claude Sonnet 5 --- .../ScimUserEndpointsMockMvcTests.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java index 63290b128d2..436e95fd7b0 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java @@ -636,6 +636,26 @@ void unlockAccount() throws Exception { .andExpect(redirectedUrl("/")); } + @Test + void unlockAccountWithoutAcceptHeader() throws Exception { + // Regression test: without @ResponseBody on updateAccountStatus, a caller + // that omits Accept: application/json (e.g. uaa-cli's unlock-user, which + // issues a raw PATCH via its curl helper) gets routed into Thymeleaf view + // resolution instead of the normal HttpMessageConverter path, and 500s. + ScimUser userToLockout = createUser(uaaAdminToken); + attemptUnsuccessfulLogin(5, userToLockout.getUserName(), ""); + + UserAccountStatus alteredAccountStatus = new UserAccountStatus(); + alteredAccountStatus.setLocked(false); + updateAccountStatusWithoutAcceptHeader(userToLockout, alteredAccountStatus) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(content().string(JsonUtils.writeValueAsString(alteredAccountStatus))); + + attemptLogin(userToLockout) + .andExpect(redirectedUrl("/")); + } + @Test void accountStatusEmptyPatchDoesNotUnlock() throws Exception { ScimUser userToLockout = createUser(uaaAdminToken); @@ -1388,6 +1408,17 @@ private ResultActions updateAccountStatus(ScimUser user, UserAccountStatus alter ); } + private ResultActions updateAccountStatusWithoutAcceptHeader(ScimUser user, UserAccountStatus alteredAccountStatus) throws Exception { + String jsonStatus = JsonUtils.writeValueAsString(alteredAccountStatus); + return mockMvc + .perform( + patch("/Users/" + user.getId() + "/status") + .header("Authorization", "Bearer " + uaaAdminToken) + .contentType(APPLICATION_JSON) + .content(jsonStatus) + ); + } + private ResultActions attemptLogin(ScimUser user) throws Exception { return mockMvc .perform(post("/login.do")