Skip to content

Commit c4e1f17

Browse files
committed
test(profile): sync cache and concurrent isolation
1 parent ddf3777 commit c4e1f17

1 file changed

Lines changed: 144 additions & 6 deletions

File tree

src/test/java/io/github/easy4j/hermes/security/ProfileAuthenticationContractTest.java

Lines changed: 144 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
import io.github.easy4j.hermes.HermesCliConfig;
66
import io.github.easy4j.hermes.HermesHttpClientConfig;
77
import io.github.easy4j.hermes.HermesOkHttpClientFactory;
8+
import io.github.easy4j.hermes.exception.HermesHttpException;
89
import io.github.easy4j.hermes.api.model.ChatRequest;
10+
import okhttp3.Cache;
11+
import okhttp3.Cookie;
12+
import okhttp3.CookieJar;
13+
import okhttp3.HttpUrl;
914
import okhttp3.MediaType;
1015
import okhttp3.OkHttpClient;
1116
import okhttp3.Protocol;
@@ -14,9 +19,19 @@
1419
import okhttp3.mockwebserver.MockResponse;
1520
import okhttp3.mockwebserver.MockWebServer;
1621
import okhttp3.mockwebserver.RecordedRequest;
22+
import okhttp3.mockwebserver.SocketPolicy;
1723
import org.junit.jupiter.api.Test;
1824

25+
import java.io.File;
26+
import java.nio.file.Files;
1927
import java.util.Collections;
28+
import java.util.HashMap;
29+
import java.util.Map;
30+
import java.util.List;
31+
import java.util.concurrent.CountDownLatch;
32+
import java.util.concurrent.ExecutorService;
33+
import java.util.concurrent.Executors;
34+
import java.util.concurrent.Future;
2035
import java.util.concurrent.TimeUnit;
2136
import java.util.concurrent.atomic.AtomicReference;
2237

@@ -42,17 +57,35 @@ void independentProfilesUseTheirOwnCredentials() throws Exception {
4257
"team-b", "credential-b",
4358
identity -> CredentialSnapshot.of("token-b", "generation-b"));
4459

45-
root.forProfile(teamA).health();
46-
root.forProfile(teamB).health();
60+
HermesClient clientA = root.forProfile(teamA);
61+
HermesClient clientB = root.forProfile(teamB);
62+
ExecutorService executor = Executors.newFixedThreadPool(2);
63+
CountDownLatch start = new CountDownLatch(1);
64+
try {
65+
Future<?> a = executor.submit(() -> {
66+
await(start);
67+
clientA.health();
68+
});
69+
Future<?> b = executor.submit(() -> {
70+
await(start);
71+
clientB.health();
72+
});
73+
start.countDown();
74+
a.get(3, TimeUnit.SECONDS);
75+
b.get(3, TimeUnit.SECONDS);
76+
} finally {
77+
executor.shutdownNow();
78+
}
4779

4880
RecordedRequest first = server.takeRequest(3, TimeUnit.SECONDS);
4981
RecordedRequest second = server.takeRequest(3, TimeUnit.SECONDS);
5082
assertNotNull(first);
5183
assertNotNull(second);
52-
assertEquals("/p/team-a/health", first.getPath());
53-
assertEquals("Bearer token-a", first.getHeader("Authorization"));
54-
assertEquals("/p/team-b/health", second.getPath());
55-
assertEquals("Bearer token-b", second.getHeader("Authorization"));
84+
Map<String, String> authByPath = new HashMap<>();
85+
authByPath.put(first.getPath(), first.getHeader("Authorization"));
86+
authByPath.put(second.getPath(), second.getHeader("Authorization"));
87+
assertEquals("Bearer token-a", authByPath.get("/p/team-a/health"));
88+
assertEquals("Bearer token-b", authByPath.get("/p/team-b/health"));
5689
}
5790
}
5891
}
@@ -133,6 +166,85 @@ void credentialSnapshotDoesNotExposeSecretInToString() {
133166
assertFalse(snapshot.toString().contains("super-secret-token"));
134167
}
135168

169+
@Test
170+
void credentialRotationAfterLostWriteDoesNotResubmitUnderNewIdentity() throws Exception {
171+
try (MockWebServer server = new MockWebServer()) {
172+
server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST));
173+
server.start();
174+
175+
AtomicReference<CredentialSnapshot> credential =
176+
new AtomicReference<>(CredentialSnapshot.of("token-a", "generation-a"));
177+
ProfileBinding binding = ProfileBinding.of(
178+
"team-a", "credential-a", identity -> credential.get());
179+
180+
try (HermesClient root = new HermesClient(http(server), disabledCli())) {
181+
HermesClient profile = root.forProfile(binding);
182+
ChatRequest request = new ChatRequest();
183+
request.setMessages(Collections.singletonList(
184+
new ChatRequest.Message("user", "side-effecting request")));
185+
186+
assertThrows(HermesHttpException.class, () -> profile.chatCompletion(request));
187+
credential.set(CredentialSnapshot.of("token-b", "generation-b"));
188+
189+
RecordedRequest accepted = server.takeRequest(3, TimeUnit.SECONDS);
190+
assertNotNull(accepted);
191+
assertEquals("Bearer token-a", accepted.getHeader("Authorization"));
192+
assertEquals(1, server.getRequestCount(),
193+
"an outcome-unknown write must not be recreated under rotated credentials");
194+
}
195+
}
196+
}
197+
198+
@Test
199+
void externalCacheIsRejectedForIsolatedProfiles() throws Exception {
200+
File directory = Files.createTempDirectory("hermes-profile-cache").toFile();
201+
Cache cache = new Cache(directory, 1024L);
202+
OkHttpClient external = new OkHttpClient.Builder().cache(cache).build();
203+
HermesHttpClientConfig http = new HermesHttpClientConfig();
204+
http.markUnsafeBaseUrlOverriddenForTest(true);
205+
http.setBaseUrl("http://127.0.0.1:8642");
206+
207+
ProfileBinding binding = ProfileBinding.of(
208+
"team-a", "credential-a",
209+
identity -> CredentialSnapshot.of("profile-token", "1"));
210+
211+
try (HermesClient root = new HermesClient(http, disabledCli(), external)) {
212+
assertThrows(IllegalStateException.class, () -> root.forProfile(binding));
213+
} finally {
214+
cache.close();
215+
HermesOkHttpClientFactory.shutdown(external);
216+
deleteRecursively(directory);
217+
}
218+
}
219+
220+
@Test
221+
void externalCookieJarIsRejectedForIsolatedProfiles() {
222+
CookieJar statefulJar = new CookieJar() {
223+
@Override
224+
public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
225+
}
226+
227+
@Override
228+
public List<Cookie> loadForRequest(HttpUrl url) {
229+
return Collections.emptyList();
230+
}
231+
};
232+
OkHttpClient external = new OkHttpClient.Builder().cookieJar(statefulJar).build();
233+
HermesHttpClientConfig http = new HermesHttpClientConfig();
234+
http.markUnsafeBaseUrlOverriddenForTest(true);
235+
http.setBaseUrl("http://127.0.0.1:8642");
236+
237+
ProfileBinding binding = ProfileBinding.of(
238+
"team-a", "credential-a",
239+
identity -> CredentialSnapshot.of("profile-token", "1"));
240+
241+
try (HermesClient root = new HermesClient(http, disabledCli(), external)) {
242+
assertThrows(IllegalStateException.class, () -> root.forProfile(binding));
243+
} finally {
244+
HermesOkHttpClientFactory.shutdown(external);
245+
}
246+
}
247+
136248
@Test
137249
void statefulExternalInterceptorIsRejectedForIsolatedProfiles() {
138250
OkHttpClient external = new OkHttpClient.Builder()
@@ -187,6 +299,32 @@ void businessHeadersCannotOverrideBoundAuthorization() throws Exception {
187299
}
188300
}
189301

302+
private static void await(CountDownLatch latch) {
303+
try {
304+
if (!latch.await(3, TimeUnit.SECONDS)) {
305+
throw new AssertionError("timed out waiting for concurrent profile start");
306+
}
307+
} catch (InterruptedException error) {
308+
Thread.currentThread().interrupt();
309+
throw new AssertionError(error);
310+
}
311+
}
312+
313+
private static void deleteRecursively(File file) {
314+
if (file == null || !file.exists()) {
315+
return;
316+
}
317+
File[] children = file.listFiles();
318+
if (children != null) {
319+
for (File child : children) {
320+
deleteRecursively(child);
321+
}
322+
}
323+
if (!file.delete() && file.exists()) {
324+
file.deleteOnExit();
325+
}
326+
}
327+
190328
private static HermesHttpClientConfig http(MockWebServer server) {
191329
return new HermesHttpClientConfig()
192330
.setEndpointPolicy(EndpointPolicy.trustedLocal("127.0.0.1", server.getPort()))

0 commit comments

Comments
 (0)