@@ -230,6 +230,9 @@ def load_secret():
230230 "options" ,
231231 [
232232 {"audience" : "one" , "resource" : "two" },
233+ {"audience" : " " },
234+ {"resource" : "" },
235+ {"resource" : 42 },
233236 {"token_url" : "http://idp.example.com/token" },
234237 {"token_url" : "https://user:secret@idp.example.com/token" },
235238 {"client_id" : "" },
@@ -273,6 +276,7 @@ def test_request_attaches_resource_token_without_forwarding_client_credentials(h
273276 ("https://api.example.com/orders" , {"redirect" : True }),
274277 ("https://api.example.com/orders" , {"retries" : 3 }),
275278 ("https://api.example.com/orders" , {"timeout" : 0 }),
279+ ("https://api.example.com/orders" , {"headers" : [("Accept" , "application/json" )]}),
276280 ],
277281)
278282def test_request_rejects_unsafe_overrides_before_acquiring_credentials (http , url , options ):
@@ -290,3 +294,107 @@ def test_request_does_not_follow_redirects_or_retry_downstream_failures(http):
290294 http .serve ("https://api.example.com/orders" , {}, status = 503 )
291295 assert subject .request ("GET" , "https://api.example.com/orders" ).status == 503
292296 assert len (http .requests ) == 3
297+
298+
299+ @pytest .mark .parametrize ("method" , [None , "" , "GET /" , "GET\r \n Injected" ])
300+ def test_invalid_http_methods_are_rejected_before_loading_credentials (http , method ):
301+ calls = []
302+
303+ def load_secret ():
304+ calls .append (True )
305+ return "test-secret"
306+
307+ with pytest .raises (ValueError , match = "HTTP method" ):
308+ client (client_secret = load_secret ).request (method , "https://api.example.com/orders" )
309+ assert calls == []
310+ assert http .requests == []
311+
312+
313+ @pytest .mark .parametrize ("secret" , [None , "" , 42 ])
314+ def test_invalid_secret_loader_results_are_rejected_before_sending_credentials (http , secret ):
315+ with pytest .raises (TokenExchangeError ) as error :
316+ client (client_secret = lambda : secret ).auth_headers ()
317+ assert error .value .__context__ is None
318+ assert http .requests == []
319+
320+
321+ def test_retry_stops_when_the_backoff_exceeds_the_remaining_budget (http , clock , monkeypatch ):
322+ sleeps = []
323+ monkeypatch .setattr (time , "sleep" , sleeps .append )
324+ http .serve (TOKEN_URL , {}, status = 503 , method = "POST" )
325+
326+ with pytest .raises (TokenExchangeError ):
327+ client (timeout_seconds = 0.05 ).auth_headers ()
328+ assert sleeps == []
329+ assert len (http .requests ) == 1
330+
331+
332+ def test_waiting_callers_share_a_failed_exchange_and_can_recover (http , monkeypatch ):
333+ entered = threading .Event ()
334+ release = threading .Event ()
335+ joined = threading .Event ()
336+
337+ def exchange ():
338+ entered .set ()
339+ assert release .wait (5 )
340+ return {"error" : "invalid_client" }
341+
342+ http .serve (TOKEN_URL , exchange , status = 401 , method = "POST" )
343+ subject = client ()
344+ with ThreadPoolExecutor (max_workers = 2 ) as executor :
345+ owner = executor .submit (subject .auth_headers )
346+ try :
347+ assert entered .wait (5 )
348+ flight = subject ._flight
349+ assert flight is not None
350+ wait = flight .done .wait
351+
352+ def observe_wait (timeout ):
353+ joined .set ()
354+ return wait (timeout )
355+
356+ # Keep the real Event; observe it so the provider is released only
357+ # after the second caller has joined the active exchange.
358+ monkeypatch .setattr (flight .done , "wait" , observe_wait )
359+ waiter = executor .submit (subject .auth_headers )
360+ assert joined .wait (5 )
361+ finally :
362+ release .set ()
363+ for result in (owner , waiter ):
364+ with pytest .raises (TokenExchangeError ) as error :
365+ result .result (timeout = 5 )
366+ assert error .value .__context__ is None
367+ assert len (http .requests ) == 1
368+
369+ http .serve (TOKEN_URL , {"access_token" : "recovered" , "token_type" : "Bearer" , "expires_in" : 100 }, method = "POST" )
370+ assert subject .auth_headers () == {"Authorization" : "Bearer recovered" }
371+ assert len (http .requests ) == 2
372+
373+
374+ def test_waiting_callers_timeout_without_returning_the_late_token (http ):
375+ entered = threading .Event ()
376+ release = threading .Event ()
377+
378+ def exchange ():
379+ entered .set ()
380+ assert release .wait (5 )
381+ return {"access_token" : "too-late" , "token_type" : "Bearer" , "expires_in" : 100 }
382+
383+ http .serve (TOKEN_URL , exchange , method = "POST" )
384+ subject = client (timeout_seconds = 0.1 )
385+ with ThreadPoolExecutor (max_workers = 1 ) as executor :
386+ owner = executor .submit (subject .auth_headers )
387+ try :
388+ assert entered .wait (5 )
389+ with pytest .raises (TokenExchangeError ):
390+ subject .auth_headers ()
391+ assert not owner .done ()
392+ assert len (http .requests ) == 1
393+ finally :
394+ release .set ()
395+ with pytest .raises (TokenExchangeError ):
396+ owner .result (timeout = 5 )
397+
398+ http .serve (TOKEN_URL , {"access_token" : "recovered" , "token_type" : "Bearer" , "expires_in" : 100 }, method = "POST" )
399+ assert subject .auth_headers () == {"Authorization" : "Bearer recovered" }
400+ assert len (http .requests ) == 2
0 commit comments