55import io .github .easy4j .hermes .HermesCliConfig ;
66import io .github .easy4j .hermes .HermesHttpClientConfig ;
77import io .github .easy4j .hermes .HermesOkHttpClientFactory ;
8+ import io .github .easy4j .hermes .exception .HermesHttpException ;
89import io .github .easy4j .hermes .api .model .ChatRequest ;
10+ import okhttp3 .Cache ;
11+ import okhttp3 .Cookie ;
12+ import okhttp3 .CookieJar ;
13+ import okhttp3 .HttpUrl ;
914import okhttp3 .MediaType ;
1015import okhttp3 .OkHttpClient ;
1116import okhttp3 .Protocol ;
1419import okhttp3 .mockwebserver .MockResponse ;
1520import okhttp3 .mockwebserver .MockWebServer ;
1621import okhttp3 .mockwebserver .RecordedRequest ;
22+ import okhttp3 .mockwebserver .SocketPolicy ;
1723import org .junit .jupiter .api .Test ;
1824
25+ import java .io .File ;
26+ import java .nio .file .Files ;
1927import 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 ;
2035import java .util .concurrent .TimeUnit ;
2136import 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