Skip to content

Commit 908e5f6

Browse files
committed
Initial implementation of unit test for concurrency issue
1 parent 9526f35 commit 908e5f6

File tree

2 files changed

+479
-0
lines changed

2 files changed

+479
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
15+
package org.cloudfoundry.reactor.tokenprovider;
16+
17+
import static org.assertj.core.api.Assertions.assertThat;
18+
19+
import java.io.IOException;
20+
import java.time.Duration;
21+
import java.util.ArrayList;
22+
import java.util.List;
23+
import java.util.concurrent.CompletableFuture;
24+
import java.util.concurrent.CountDownLatch;
25+
import java.util.concurrent.ExecutorService;
26+
import java.util.concurrent.Executors;
27+
import java.util.concurrent.TimeUnit;
28+
29+
import org.cloudfoundry.reactor.ConnectionContext;
30+
import org.cloudfoundry.reactor.DefaultConnectionContext;
31+
import org.junit.jupiter.api.AfterEach;
32+
import org.junit.jupiter.api.BeforeEach;
33+
import org.junit.jupiter.api.Test;
34+
import org.slf4j.Logger;
35+
import org.slf4j.LoggerFactory;
36+
37+
import reactor.netty.http.client.HttpClientForm;
38+
import reactor.netty.http.client.HttpClientRequest;
39+
40+
/**
41+
* Integration-style tests for AbstractUaaTokenProvider that verify the
42+
* corrected behavior for concurrent token requests with expired access tokens.
43+
*
44+
* These tests verify the fix for issue #1146: "Parallel Requests with Expired
45+
* Access Tokens triggering Refresh Token Flow leads to Broken State".
46+
*/
47+
class AbstractUaaTokenProviderConcurrencyTest {
48+
49+
private static final Logger LOGGER = LoggerFactory.getLogger(AbstractUaaTokenProviderConcurrencyTest.class);
50+
51+
private MockUaaServer mockUaaServer;
52+
private ConnectionContext connectionContext;
53+
private TestTokenProvider tokenProvider;
54+
private ExecutorService executorService;
55+
56+
@BeforeEach
57+
void setUp() throws IOException {
58+
mockUaaServer = new MockUaaServer();
59+
60+
// Extract port from URL like "http://localhost:12345/"
61+
final String baseUrl = mockUaaServer.getBaseUrl();
62+
final int port = Integer.parseInt(baseUrl.split(":")[2].split("/")[0]);
63+
64+
connectionContext = DefaultConnectionContext.builder()
65+
.apiHost("localhost")
66+
.port(port)
67+
.secure(false)
68+
.cacheDuration(Duration.ofMillis(100)) // Short cache for testing
69+
.build();
70+
71+
tokenProvider = new TestTokenProvider();
72+
executorService = Executors.newFixedThreadPool(10);
73+
}
74+
75+
@AfterEach
76+
void tearDown() throws IOException {
77+
if (mockUaaServer != null) {
78+
mockUaaServer.shutdown();
79+
}
80+
if (executorService != null) {
81+
executorService.shutdown();
82+
}
83+
}
84+
85+
/**
86+
* Test that concurrent token requests don't cause broken state.
87+
* This is the main test case for issue #1146.
88+
*/
89+
@Test
90+
void concurrentTokenRequestsWithRefreshTokenRotation() throws Exception {
91+
// Set up initial refresh token
92+
mockUaaServer.setInitialRefreshToken("initial-refresh-token");
93+
94+
// Get initial token to establish refresh token
95+
final String initialToken = tokenProvider.getToken(connectionContext).block(Duration.ofSeconds(5));
96+
assertThat(initialToken).isNotNull();
97+
98+
// Reset request count to focus on concurrent requests
99+
mockUaaServer.resetRequestCount();
100+
101+
// Invalidate the token to force refresh on next request
102+
tokenProvider.invalidate(connectionContext);
103+
104+
// Launch multiple concurrent requests
105+
final int concurrentRequests = 5;
106+
final CountDownLatch startLatch = new CountDownLatch(1);
107+
final List<CompletableFuture<String>> futures = new ArrayList<>();
108+
109+
for (int i = 0; i < concurrentRequests; i++) {
110+
final CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
111+
try {
112+
startLatch.await(5, TimeUnit.SECONDS);
113+
return tokenProvider.getToken(connectionContext).block(Duration.ofSeconds(10));
114+
} catch (final Exception e) {
115+
LOGGER.error("Error getting token", e);
116+
throw new RuntimeException(e);
117+
}
118+
}, executorService);
119+
futures.add(future);
120+
}
121+
122+
// Start all requests simultaneously
123+
startLatch.countDown();
124+
125+
// Wait for all requests to complete
126+
final List<String> tokens = new ArrayList<>();
127+
for (final CompletableFuture<String> future : futures) {
128+
final String token = future.get(15, TimeUnit.SECONDS);
129+
assertThat(token).isNotNull();
130+
tokens.add(token);
131+
}
132+
133+
// Verify all tokens are valid (not null/empty)
134+
assertThat(tokens).hasSize(concurrentRequests);
135+
for (final String token : tokens) {
136+
assertThat(token).isNotNull().isNotEmpty();
137+
}
138+
139+
// Verify that subsequent requests still work (no broken state)
140+
final String subsequentToken = tokenProvider.getToken(connectionContext).block(Duration.ofSeconds(5));
141+
assertThat(subsequentToken).isNotNull();
142+
143+
LOGGER.info("Concurrent test completed successfully. Total UAA requests: {}", mockUaaServer.getRequestCount());
144+
}
145+
146+
/**
147+
* Test that the token provider handles UAA server errors gracefully during
148+
* concurrent requests.
149+
*/
150+
@Test
151+
void concurrentTokenRequestsWithServerErrors() throws Exception {
152+
// Set up initial refresh token
153+
mockUaaServer.setInitialRefreshToken("initial-refresh-token");
154+
155+
// Get initial token
156+
final String initialToken = tokenProvider.getToken(connectionContext).block(Duration.ofSeconds(5));
157+
assertThat(initialToken).isNotNull();
158+
159+
// Configure server to fail the first few refresh requests
160+
mockUaaServer.setShouldFailRefreshRequests(true, 2);
161+
162+
// Invalidate to force refresh
163+
tokenProvider.invalidate(connectionContext);
164+
165+
// Launch concurrent requests
166+
final int concurrentRequests = 3;
167+
final CountDownLatch startLatch = new CountDownLatch(1);
168+
final List<CompletableFuture<String>> futures = new ArrayList<>();
169+
170+
for (int i = 0; i < concurrentRequests; i++) {
171+
final CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
172+
try {
173+
startLatch.await(5, TimeUnit.SECONDS);
174+
return tokenProvider.getToken(connectionContext).block(Duration.ofSeconds(10));
175+
} catch (final Exception e) {
176+
LOGGER.error("Error getting token", e);
177+
return null; // Allow some failures
178+
}
179+
}, executorService);
180+
futures.add(future);
181+
}
182+
183+
startLatch.countDown();
184+
185+
// Collect results (some may be null due to failures)
186+
final List<String> tokens = new ArrayList<>();
187+
for (final CompletableFuture<String> future : futures) {
188+
final String token = future.get(15, TimeUnit.SECONDS);
189+
if (token != null) {
190+
tokens.add(token);
191+
}
192+
}
193+
194+
// At least one request should succeed eventually
195+
assertThat(tokens).isNotEmpty();
196+
197+
// Verify system recovers and subsequent requests work
198+
final String recoveryToken = tokenProvider.getToken(connectionContext).block(Duration.ofSeconds(5));
199+
assertThat(recoveryToken).isNotNull();
200+
201+
LOGGER.info("Error handling test completed. Successful tokens: {}, Total UAA requests: {}",
202+
tokens.size(), mockUaaServer.getRequestCount());
203+
}
204+
205+
/**
206+
* Test that the token provider properly serializes token requests to prevent
207+
* multiple concurrent UAA requests with the same refresh token.
208+
*/
209+
@Test
210+
void tokenRequestSerialization() throws Exception {
211+
// Set up initial refresh token
212+
mockUaaServer.setInitialRefreshToken("initial-refresh-token");
213+
214+
// Get initial token
215+
final String initialToken = tokenProvider.getToken(connectionContext).block(Duration.ofSeconds(5));
216+
assertThat(initialToken).isNotNull();
217+
218+
final int initialRequestCount = mockUaaServer.getRequestCount();
219+
220+
// Invalidate to force refresh
221+
tokenProvider.invalidate(connectionContext);
222+
223+
// Launch many concurrent requests
224+
final int concurrentRequests = 10;
225+
final CountDownLatch startLatch = new CountDownLatch(1);
226+
final List<CompletableFuture<String>> futures = new ArrayList<>();
227+
228+
for (int i = 0; i < concurrentRequests; i++) {
229+
final CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
230+
try {
231+
startLatch.await(5, TimeUnit.SECONDS);
232+
return tokenProvider.getToken(connectionContext).block(Duration.ofSeconds(10));
233+
} catch (final Exception e) {
234+
LOGGER.error("Error getting token", e);
235+
throw new RuntimeException(e);
236+
}
237+
}, executorService);
238+
futures.add(future);
239+
}
240+
241+
startLatch.countDown();
242+
243+
// Wait for all to complete
244+
for (final CompletableFuture<String> future : futures) {
245+
final String token = future.get(15, TimeUnit.SECONDS);
246+
assertThat(token).isNotNull();
247+
}
248+
249+
final int finalRequestCount = mockUaaServer.getRequestCount();
250+
final int newRequests = finalRequestCount - initialRequestCount;
251+
252+
// The key assertion: despite many concurrent requests, only a minimal number of
253+
// actual UAA requests should be made due to proper serialization and caching
254+
assertThat(newRequests).isLessThanOrEqualTo(3); // Allow some margin for timing
255+
256+
LOGGER.info("Serialization test completed. Concurrent requests: {}, Actual UAA requests: {}",
257+
concurrentRequests, newRequests);
258+
}
259+
260+
/**
261+
* Test token provider implementation for testing purposes.
262+
*/
263+
private static class TestTokenProvider extends AbstractUaaTokenProvider {
264+
265+
@Override
266+
String getIdentityZoneSubdomain() {
267+
return null;
268+
}
269+
270+
@Override
271+
void tokenRequestTransformer(final HttpClientRequest request, final HttpClientForm form) {
272+
form.multipart(false)
273+
.attr("client_id", getClientId())
274+
.attr("client_secret", getClientSecret())
275+
.attr("grant_type", "password")
276+
.attr("username", "test-user")
277+
.attr("password", "test-password");
278+
}
279+
}
280+
}

0 commit comments

Comments
 (0)