Skip to content

Commit 895cf31

Browse files
committed
[Prod/Test] Implement ProfileController/InvitationsController zone path
1 parent acac5fa commit 895cf31

File tree

7 files changed

+177
-34
lines changed

7 files changed

+177
-34
lines changed

docs/path-based-zones-endpoints-without-z-support.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ This document lists endpoints that do **not** yet have a dual path mapping for `
1919
6.[Home and error pages (UI)](#6-home-and-error-pages-ui)
2020
7.[Session (UI)](#7-session-ui)
2121
8.[Invitations (UI + API)](#8-invitations-ui--api)
22-
9. [Profile (UI)](#9-profile-ui)
22+
9. [Profile (UI)](#9-profile-ui)
2323
10.[Passcode (API / UI)](#10-passcode-api--ui)
2424
11.[OAuth / token / client admin (API)](#11-oauth--token--client-admin-api-not-yet-covered-by-z)
2525
12.[Authenticate (API)](#12-authenticate-api)

server/src/main/java/org/cloudfoundry/identity/uaa/account/ProfileController.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public ProfileController(final ApprovalStore approvalsService,
5757
/**
5858
* Display the current user's approvals
5959
*/
60-
@GetMapping("/profile")
60+
@GetMapping({"/profile", "/z/{subdomain}/profile"})
6161
public String get(Authentication authentication, Model model) {
6262
Map<String, List<DescribedApproval>> approvals = getCurrentApprovalsForUser(getCurrentUserId());
6363
Map<String, String> clientNames = getClientNames(approvals);
@@ -70,7 +70,7 @@ public String get(Authentication authentication, Model model) {
7070
/**
7171
* Handle form post for revoking chosen approvals
7272
*/
73-
@PostMapping({"/profile", "/profile/"})
73+
@PostMapping({"/profile", "/profile/", "/z/{subdomain}/profile", "/z/{subdomain}/profile/"})
7474
public String post(@RequestParam(required = false) Collection<String> checkedScopes,
7575
@RequestParam(required = false) String update,
7676
@RequestParam(required = false) String delete,

server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsController.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,11 @@ private String processErrorReload(String code, Model model, HttpServletResponse
308308
try {
309309
String newCode = expiringCodeStore.generateCode(expiringCode.getData(), new Timestamp(System.currentTimeMillis() + (10 * 60 * 1000)), expiringCode.getIntent(), identityZoneManager.getCurrentIdentityZoneId()).getCode();
310310

311+
// Add in order so RedirectView produces accept?error_message_code=...&code=... (pattern expected by tests)
311312
model.addAttribute(errorCode, error);
312313
model.addAttribute("code", newCode);
313-
String redirectTarget = (pathPrefix != null && !pathPrefix.isEmpty()) ? pathPrefix + "/invitations/accept" : "accept";
314-
return "redirect:" + redirectTarget;
314+
String baseTarget = (pathPrefix != null && !pathPrefix.isEmpty()) ? pathPrefix + "/invitations/accept" : "accept";
315+
return "redirect:" + baseTarget;
315316
} catch (EmptyResultDataAccessException noProviderFound) {
316317
log.debug("No available invitation providers for email:%s, id:%s".formatted(codeData.get(EMAIL), codeData.get("user_id")));
317318
model.addAttribute("pathPrefix", pathPrefix);

server/src/main/resources/templates/web/invitations/accept_invite.html

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,25 @@ <h1><th:block th:unless="${isLdap}">Create</th:block><th:block th:if="${isLdap}"
1212
<p th:text="#{'account_activation.' + ${error_message_code}}">Error Message</p>
1313
</div>
1414
<div th:if="${error_message}" class="alert alert-error">
15-
<p th:text="#{'account_activation.' + ${error_message}}">Error Message</p>
15+
<p th:text="${#messages.msgOrNull('account_activation.' + error_message) ?: error_message}">Error Message</p>
1616
</div>
1717
<th:block th:unless="${error_message_code == 'code_expired'}" >
1818
<th:block th:if="${provider == 'uaa'}">
19-
<form th:action="${pathPrefix != null && !#strings.isEmpty(pathPrefix) ? pathPrefix + '/invitations/accept.do' : '/invitations/accept.do'}" method="post" novalidate="novalidate">
19+
<form th:if="${pathPrefix != null && !#strings.isEmpty(pathPrefix)}" th:action="${pathPrefix + '/invitations/accept.do'}" method="post" novalidate="novalidate">
20+
<input name="code" type="hidden" value="code" th:value="${code}"/>
21+
<input name="password" type="password" placeholder="Password" aria-label="Password" autocomplete="off" class="form-control"/>
22+
<input name="password_confirmation" type="password" placeholder="Confirm" aria-label="Confirm Password" autocomplete="off" class="form-control"/>
23+
24+
<label th:if="${consent_text}" style="margin-bottom: 20px">
25+
<input name="does_user_consent" type="checkbox" style="display: inline">
26+
<span>I agree to </span>
27+
<span th:if="!${consent_link}" th:text="${consent_text}"></span>
28+
<a th:if="${consent_link}" th:text="${consent_text}" name="consent-link" th:href="@{${consent_link}}" target="_blank" rel="noopener noreferrer"></a>
29+
</label>
30+
31+
<input type="submit" th:value="${!companyName.equals('Cloud Foundry') and isUaa ? 'Create ' + companyName + ' account' : 'Create account'}" class="island-button"/>
32+
</form>
33+
<form th:unless="${pathPrefix != null && !#strings.isEmpty(pathPrefix)}" th:action="@{/invitations/accept.do}" method="post" novalidate="novalidate">
2034
<input name="code" type="hidden" value="code" th:value="${code}"/>
2135
<input name="password" type="password" placeholder="Password" aria-label="Password" autocomplete="off" class="form-control"/>
2236
<input name="password_confirmation" type="password" placeholder="Confirm" aria-label="Confirm Password" autocomplete="off" class="form-control"/>
@@ -33,7 +47,14 @@ <h1><th:block th:unless="${isLdap}">Create</th:block><th:block th:if="${isLdap}"
3347
</th:block>
3448
<th:block th:if="${isLdap}">
3549
<p>Sign in with enterprise credentials: </p>
36-
<form th:action="${pathPrefix != null && !#strings.isEmpty(pathPrefix) ? pathPrefix + '/invitations/accept_enterprise.do' : '/invitations/accept_enterprise.do'}" method="post" novalidate="novalidate">
50+
<form th:if="${pathPrefix != null && !#strings.isEmpty(pathPrefix)}" th:action="${pathPrefix + '/invitations/accept_enterprise.do'}" method="post" novalidate="novalidate">
51+
<input name="enterprise_email" type="hidden" th:value="${email}"/>
52+
<input name="code" type="hidden" value="code" th:value="${code}"/>
53+
<input name="enterprise_username" type="text" placeholder="Username" aria-label="Username" autocomplete="off" class="form-control"/>
54+
<input name="enterprise_password" type="password" placeholder="Password" aria-label="Password" autocomplete="off" class="form-control"/>
55+
<input type="submit" value="Sign in" class="island-button"/>
56+
</form>
57+
<form th:unless="${pathPrefix != null && !#strings.isEmpty(pathPrefix)}" th:action="@{/invitations/accept_enterprise.do}" method="post" novalidate="novalidate">
3758
<input name="enterprise_email" type="hidden" th:value="${email}"/>
3859
<input name="code" type="hidden" value="code" th:value="${code}"/>
3960
<input name="enterprise_username" type="text" placeholder="Username" aria-label="Username" autocomplete="off" class="form-control"/>

server/src/main/resources/templates/web/nav.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
<span sec:authentication="name">user@example.com</span>
1515
<i class="fa fa-chevron-down"></i>
1616
</button>
17-
<ul class="dropdown-content" id="nav-dropdown-content">
18-
<li><a href="/profile" id="nav-dropdown-content-profile" th:href="${pathPrefix != null && !#strings.isEmpty(pathPrefix) ? pathPrefix + '/profile' : '/profile'}">Account Settings</a></li>
19-
<li><a href="/logout.do" id="nav-dropdown-content-logout" th:href="${pathPrefix != null && !#strings.isEmpty(pathPrefix) ? pathPrefix + '/logout.do' : '/logout.do'}">Sign Out</a></li>
17+
<ul class="dropdown-content" id="nav-dropdown-content" th:with="defaultProfileUrl=@{/profile}, defaultLogoutUrl=@{/logout.do}">
18+
<li><a href="/profile" id="nav-dropdown-content-profile" th:href="${pathPrefix != null && !#strings.isEmpty(pathPrefix) ? pathPrefix + '/profile' : defaultProfileUrl}">Account Settings</a></li>
19+
<li><a href="/logout.do" id="nav-dropdown-content-logout" th:href="${pathPrefix != null && !#strings.isEmpty(pathPrefix) ? pathPrefix + '/logout.do' : defaultLogoutUrl}">Sign Out</a></li>
2020
</ul>
2121
</div>
2222
</th:block>

server/src/test/java/org/cloudfoundry/identity/uaa/login/ProfileControllerMockMvcTests.java

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,23 @@
1010
import org.cloudfoundry.identity.uaa.constants.OriginKeys;
1111
import org.cloudfoundry.identity.uaa.extensions.PollutionPreventionExtension;
1212
import org.cloudfoundry.identity.uaa.home.BuildInfo;
13+
import org.cloudfoundry.identity.uaa.util.ZoneResolutionMode;
1314
import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants;
1415
import org.cloudfoundry.identity.uaa.security.beans.SecurityContextAccessor;
1516
import org.cloudfoundry.identity.uaa.util.beans.TestBuildInfo;
1617
import org.cloudfoundry.identity.uaa.zone.MultitenantClientServices;
1718
import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager;
1819
import org.junit.jupiter.api.AfterEach;
1920
import org.junit.jupiter.api.BeforeEach;
20-
import org.junit.jupiter.api.Test;
2121
import org.junit.jupiter.api.extension.ExtendWith;
2222
import org.junit.jupiter.params.ParameterizedTest;
23+
import org.junit.jupiter.params.provider.Arguments;
24+
import org.junit.jupiter.params.provider.EnumSource;
25+
import org.junit.jupiter.params.provider.MethodSource;
2326
import org.junit.jupiter.params.provider.ValueSource;
27+
import org.springframework.http.HttpMethod;
28+
29+
import java.util.stream.Stream;
2430
import org.mockito.ArgumentCaptor;
2531
import org.mockito.Mockito;
2632
import org.springframework.beans.factory.annotation.Autowired;
@@ -67,6 +73,10 @@
6773
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
6874
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
6975

76+
/**
77+
* MockMvc tests for ProfileController. Parameterized by {@link ZoneResolutionMode} (SUBDOMAIN and ZONE_PATH).
78+
* ZONE_PATH tests are expected to fail until ProfileController adds mappings for {@code /z/{subdomain}/profile}.
79+
*/
7080
@ExtendWith(PollutionPreventionExtension.class)
7181
@WebAppConfiguration
7282
@SpringJUnitConfig(classes = ProfileControllerMockMvcTests.ContextConfiguration.class)
@@ -128,6 +138,8 @@ ProfileController profileController(ApprovalStore approvalsService,
128138

129139
private static final String THE_ULTIMATE_APP = "The Ultimate App";
130140
private static final String USER_ID = "userId";
141+
/** Non-empty subdomain for ZONE_PATH so request goes to /z/{subdomain}/profile (expected 404 until controller has zone path). */
142+
private static final String ZONE_PATH_SUBDOMAIN = "test-zone";
131143

132144
@Autowired
133145
private WebApplicationContext webApplicationContext;
@@ -195,53 +207,75 @@ void tearDown() {
195207
SecurityContextHolder.clearContext();
196208
}
197209

198-
@Test
199-
void getProfile() throws Exception {
200-
getProfile(mockMvc, THE_ULTIMATE_APP, currentIdentityZoneId);
210+
private String subdomainFor(ZoneResolutionMode mode) {
211+
return mode == ZoneResolutionMode.ZONE_PATH ? ZONE_PATH_SUBDOMAIN : "";
201212
}
202213

203-
@Test
204-
void getProfileNoAppName() throws Exception {
214+
@ParameterizedTest
215+
@EnumSource(ZoneResolutionMode.class)
216+
void getProfile(ZoneResolutionMode mode) throws Exception {
217+
String subdomain = subdomainFor(mode);
218+
getProfile(mockMvc, mode, subdomain, THE_ULTIMATE_APP, currentIdentityZoneId);
219+
}
220+
221+
@ParameterizedTest
222+
@EnumSource(ZoneResolutionMode.class)
223+
void getProfileNoAppName(ZoneResolutionMode mode) throws Exception {
205224
UaaClientDetails appClient = new UaaClientDetails("app", "thing", "thing.read,thing.write", GRANT_TYPE_AUTHORIZATION_CODE, "");
206225
when(clientDetailsService.loadClientByClientId("app", currentIdentityZoneId)).thenReturn(appClient);
207-
getProfile(mockMvc, "app", currentIdentityZoneId);
226+
String subdomain = subdomainFor(mode);
227+
getProfile(mockMvc, mode, subdomain, "app", currentIdentityZoneId);
208228
}
209229

210-
@Test
211-
void specialMessageWhenNoAppsAreAuthorized() throws Exception {
230+
@ParameterizedTest
231+
@EnumSource(ZoneResolutionMode.class)
232+
void specialMessageWhenNoAppsAreAuthorized(ZoneResolutionMode mode) throws Exception {
212233
when(approvalStore.getApprovalsForUser(anyString(), eq(currentIdentityZoneId))).thenReturn(Collections.emptyList());
213234

214235
UaaPrincipal uaaPrincipal = new UaaPrincipal("fake-user-id", "username", "email@example.com", OriginKeys.UAA, null, currentIdentityZoneId);
215236
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(uaaPrincipal, null);
237+
String subdomain = subdomainFor(mode);
216238

217-
mockMvc.perform(get("/profile").principal(authentication))
239+
mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/profile").principal(authentication))
218240
.andExpect(status().isOk())
219241
.andExpect(model().attributeExists("approvals"))
220242
.andExpect(content().contentTypeCompatibleWith(TEXT_HTML))
221243
.andExpect(content().string(containsString("You have not yet authorized any third party applications.")));
222244
}
223245

224-
@Test
225-
void passwordLinkHiddenWhenUsersOriginIsNotUaa() throws Exception {
246+
@ParameterizedTest
247+
@EnumSource(ZoneResolutionMode.class)
248+
void passwordLinkHiddenWhenUsersOriginIsNotUaa(ZoneResolutionMode mode) throws Exception {
226249
UaaPrincipal uaaPrincipal = new UaaPrincipal("fake-user-id", "username", "email@example.com", OriginKeys.LDAP, "dnEntryForLdapUser", currentIdentityZoneId);
227250
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(uaaPrincipal, null);
251+
String subdomain = subdomainFor(mode);
228252

229-
mockMvc.perform(get("/profile").principal(authentication))
253+
mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/profile").principal(authentication))
230254
.andExpect(status().isOk())
231255
.andExpect(model().attribute("isUaaManagedUser", false))
232256
.andExpect(model().attributeDoesNotExist("email"))
233257
.andExpect(content().string(not(containsString("Change Password"))));
234258
}
235259

260+
static Stream<Arguments> updateProfilePaths() {
261+
return Stream.of(
262+
Arguments.of(ZoneResolutionMode.SUBDOMAIN, "/profile"),
263+
Arguments.of(ZoneResolutionMode.SUBDOMAIN, "/profile/"),
264+
Arguments.of(ZoneResolutionMode.ZONE_PATH, "/profile"),
265+
Arguments.of(ZoneResolutionMode.ZONE_PATH, "/profile/")
266+
);
267+
}
268+
236269
@ParameterizedTest
237-
@ValueSource(strings = {"/profile", "/profile/"})
238-
void updateProfile(String url) throws Exception {
239-
MockHttpServletRequestBuilder post = post(url)
270+
@MethodSource("updateProfilePaths")
271+
void updateProfile(ZoneResolutionMode mode, String url) throws Exception {
272+
String subdomain = subdomainFor(mode);
273+
MockHttpServletRequestBuilder postReq = mode.createRequestBuilder(subdomain, HttpMethod.POST, url)
240274
.param("checkedScopes", "app-thing.read")
241275
.param("update", "")
242276
.param("clientId", "app");
243277

244-
mockMvc.perform(post)
278+
mockMvc.perform(postReq)
245279
.andExpect(status().isFound())
246280
.andExpect(redirectedUrl("profile"));
247281

@@ -267,25 +301,27 @@ void updateProfile(String url) throws Exception {
267301
assertThat(writeApproval.getStatus()).isEqualTo(DENIED);
268302
}
269303

270-
@Test
271-
void revokeApp() throws Exception {
272-
MockHttpServletRequestBuilder post = post("/profile")
304+
@ParameterizedTest
305+
@EnumSource(ZoneResolutionMode.class)
306+
void revokeApp(ZoneResolutionMode mode) throws Exception {
307+
String subdomain = subdomainFor(mode);
308+
MockHttpServletRequestBuilder postReq = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/profile")
273309
.param("checkedScopes", "app-resource.read")
274310
.param("delete", "")
275311
.param("clientId", "app");
276312

277-
mockMvc.perform(post)
313+
mockMvc.perform(postReq)
278314
.andExpect(status().isFound())
279315
.andExpect(redirectedUrl("profile"));
280316

281317
Mockito.verify(approvalStore, Mockito.times(1)).revokeApprovalsForClientAndUser("app", USER_ID, currentIdentityZoneId);
282318
}
283319

284-
private static void getProfile(final MockMvc mockMvc, final String name, final String currentIdentityZoneId) throws Exception {
320+
private static void getProfile(final MockMvc mockMvc, final ZoneResolutionMode mode, final String subdomain, final String name, final String currentIdentityZoneId) throws Exception {
285321
UaaPrincipal uaaPrincipal = new UaaPrincipal("fake-user-id", "username", "email@example.com", OriginKeys.UAA, null, currentIdentityZoneId);
286322
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(uaaPrincipal, null);
287323

288-
mockMvc.perform(get("/profile").principal(authentication))
324+
mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/profile").principal(authentication))
289325
.andExpect(status().isOk())
290326
.andExpect(model().attributeExists("clientnames"))
291327
.andExpect(model().attribute("clientnames", hasKey("app")))

0 commit comments

Comments
 (0)