1+ import itertools
12import json
23import socket
34from typing import Any
1617 sign_webhook_payload ,
1718)
1819
20+ pytestmark = pytest .mark .usefixtures ("no_retry_backoff" )
21+
1922
2023class TestIsPrivateIP :
2124 """Test suite for _is_private_ip function."""
@@ -237,7 +240,7 @@ def test_successful_callback(
237240 """Test successful callback execution."""
238241 mock_session = MagicMock ()
239242 mock_response = MagicMock ()
240- mock_response .raise_for_status . return_value = None
243+ mock_response .status_code = 200
241244 mock_response .iter_content .return_value = [b"test" ]
242245 mock_session .post .return_value = mock_response
243246 mock_session_class .return_value .__enter__ .return_value = mock_session
@@ -272,7 +275,7 @@ def test_callback_http_error(
272275 """Test that callback returns False on HTTP errors."""
273276 mock_session = MagicMock ()
274277 mock_response = MagicMock ()
275- mock_response .raise_for_status . side_effect = requests . HTTPError ( " 404 Not Found" )
278+ mock_response .status_code = 404
276279 mock_session .post .return_value = mock_response
277280 mock_session_class .return_value .__enter__ .return_value = mock_session
278281
@@ -288,7 +291,7 @@ def test_callback_disables_redirects(
288291 """Test that redirects are disabled to prevent redirect-based SSRF."""
289292 mock_session = MagicMock ()
290293 mock_response = MagicMock ()
291- mock_response .raise_for_status . return_value = None
294+ mock_response .status_code = 200
292295 mock_response .iter_content .return_value = [b"test" ]
293296 mock_session .post .return_value = mock_response
294297 mock_session_class .return_value .__enter__ .return_value = mock_session
@@ -306,7 +309,7 @@ def test_callback_uses_timeout(
306309 """Test that callback uses configured timeouts."""
307310 mock_session = MagicMock ()
308311 mock_response = MagicMock ()
309- mock_response .raise_for_status . return_value = None
312+ mock_response .status_code = 200
310313 mock_response .iter_content .return_value = [b"test" ]
311314 mock_session .post .return_value = mock_response
312315 mock_session_class .return_value .__enter__ .return_value = mock_session
@@ -326,7 +329,7 @@ def test_callback_sends_json_data(
326329 """Test that callback sends data as JSON."""
327330 mock_session = MagicMock ()
328331 mock_response = MagicMock ()
329- mock_response .raise_for_status . return_value = None
332+ mock_response .status_code = 200
330333 mock_response .iter_content .return_value = [b"test" ]
331334 mock_session .post .return_value = mock_response
332335 mock_session_class .return_value .__enter__ .return_value = mock_session
@@ -378,3 +381,57 @@ def test_signature_matches_manual_hmac(self):
378381 sig , returned_ts = sign_webhook_payload (secret , body , timestamp_ms = ts )
379382 assert sig == expected
380383 assert returned_ts == ts
384+
385+
386+ def _callback_response (status_code : int ) -> requests .Response :
387+ response = requests .Response ()
388+ response .status_code = status_code
389+ return response
390+
391+
392+ @patch ("app.utils.validate_callback_url" )
393+ class TestSendCallbackRetry :
394+ URL = "https://api.example.com/callback"
395+
396+ def test_recovers_after_transient_failures (self , _validate : Any ) -> None :
397+ # 302 counts as a failure: redirects are off, so nothing was delivered.
398+ outcomes = [
399+ _callback_response (302 ),
400+ requests .ConnectionError ("reset" ),
401+ _callback_response (200 ),
402+ ]
403+ with patch .object (requests .Session , "post" , side_effect = outcomes ) as post :
404+ assert send_callback (self .URL , {"status" : "done" }) is True
405+ assert post .call_count == 3
406+
407+ @pytest .mark .parametrize ("status_code" , [503 , 404 ])
408+ def test_gives_up_after_four_attempts (
409+ self , _validate : Any , status_code : int
410+ ) -> None :
411+ with patch .object (
412+ requests .Session , "post" , return_value = _callback_response (status_code )
413+ ) as post :
414+ assert send_callback (self .URL , {"status" : "done" }) is False
415+ assert post .call_count == 4
416+
417+ def test_each_attempt_is_signed_with_a_fresh_timestamp (
418+ self , _validate : Any
419+ ) -> None :
420+ with (
421+ patch ("app.utils.time.time" , side_effect = itertools .count (1_700_000_000 )),
422+ patch .object (
423+ requests .Session , "post" , return_value = _callback_response (503 )
424+ ) as post ,
425+ ):
426+ send_callback (self .URL , {"status" : "done" }, webhook_secret = "s3cret" )
427+
428+ timestamps = {
429+ c .kwargs ["headers" ]["X-Webhook-Timestamp" ] for c in post .mock_calls
430+ }
431+ assert len (timestamps ) == 4
432+
433+ def test_invalid_url_is_never_posted (self , validate : Any ) -> None :
434+ validate .side_effect = ValueError ("private IP" )
435+ with patch .object (requests .Session , "post" ) as post :
436+ assert send_callback (self .URL , {"status" : "done" }) is False
437+ post .assert_not_called ()
0 commit comments