55
66import pytest
77from fastmcp .exceptions import ValidationError
8- from gg_api_core .utils import get_client , get_mcp_port_or_none , is_multi_tenant_mode
8+ from gg_api_core .utils import _get_caller_user_agent , get_client , get_mcp_port_or_none , is_multi_tenant_mode
99
1010
1111class TestGetMcpPortOrNone :
@@ -128,7 +128,7 @@ async def test_multi_tenant_extracts_token_from_headers(self, mock_client_class,
128128 GIVEN MULTI_TENANCY_ENABLED=true and MCP_PORT is set
129129 AND Authorization header is present
130130 WHEN get_client is called
131- THEN it extracts token from headers and creates new client
131+ THEN it extracts token and user-agent from headers and creates new client
132132 """
133133 mock_get_headers .return_value = {"authorization" : "Bearer request-token" }
134134 mock_client = MagicMock ()
@@ -140,6 +140,31 @@ async def test_multi_tenant_extracts_token_from_headers(self, mock_client_class,
140140 mock_client_class .assert_called_once_with (personal_access_token = "request-token" , user_agent = None )
141141 assert result == mock_client
142142
143+ @patch ("gg_api_core.utils.get_http_headers" )
144+ @patch ("gg_api_core.utils.GitGuardianClient" )
145+ async def test_multi_tenant_forwards_caller_user_agent (self , mock_client_class , mock_get_headers ):
146+ """
147+ GIVEN MULTI_TENANCY_ENABLED=true and MCP_PORT is set
148+ AND request has both Authorization and User-Agent headers
149+ WHEN get_client is called
150+ THEN the caller's User-Agent is forwarded to GitGuardianClient
151+ """
152+ mock_get_headers .return_value = {
153+ "authorization" : "Bearer request-token" ,
154+ "user-agent" : "GitGuardian-In-App-Agent" ,
155+ }
156+ mock_client = MagicMock ()
157+ mock_client_class .return_value = mock_client
158+
159+ with patch .dict (os .environ , {"MULTI_TENANCY_ENABLED" : "true" , "MCP_PORT" : "8080" }, clear = True ):
160+ result = await get_client ()
161+
162+ mock_client_class .assert_called_once_with (
163+ personal_access_token = "request-token" ,
164+ user_agent = "GitGuardian-In-App-Agent" ,
165+ )
166+ assert result == mock_client
167+
143168 @patch ("gg_api_core.utils.get_http_headers" )
144169 @patch ("gg_api_core.utils.GitGuardianClient" )
145170 async def test_multi_tenant_creates_new_client_per_request (self , mock_client_class , mock_get_headers ):
@@ -148,9 +173,13 @@ async def test_multi_tenant_creates_new_client_per_request(self, mock_client_cla
148173 WHEN get_client is called multiple times with different tokens
149174 THEN it creates a new client each time (no singleton)
150175 """
176+ # get_http_headers is called twice per get_client():
177+ # once for user-agent extraction, once for authorization extraction
151178 mock_get_headers .side_effect = [
152- {"authorization" : "Bearer token1" },
153- {"authorization" : "Bearer token2" },
179+ {"authorization" : "Bearer token1" , "user-agent" : "Agent1" },
180+ {"authorization" : "Bearer token1" , "user-agent" : "Agent1" },
181+ {"authorization" : "Bearer token2" , "user-agent" : "Agent2" },
182+ {"authorization" : "Bearer token2" , "user-agent" : "Agent2" },
154183 ]
155184 mock_client1 = MagicMock ()
156185 mock_client2 = MagicMock ()
@@ -338,3 +367,99 @@ async def test_multi_tenant_never_uses_singleton(self, mock_client_class, mock_g
338367 assert mock_client_class .call_count == 3
339368 # Singleton should remain None
340369 assert gg_api_core .utils ._client_singleton is None
370+
371+
372+ class TestCallerUserAgentExtraction :
373+ """Tests for _get_caller_user_agent() and automatic user-agent forwarding."""
374+
375+ @patch ("gg_api_core.utils.get_http_headers" )
376+ def test_extracts_user_agent_from_headers (self , mock_get_headers ):
377+ """
378+ GIVEN an HTTP request with a User-Agent header
379+ WHEN _get_caller_user_agent is called
380+ THEN it returns the user-agent string
381+ """
382+ mock_get_headers .return_value = {"user-agent" : "GitGuardian-In-App-Agent" }
383+
384+ result = _get_caller_user_agent ()
385+
386+ assert result == "GitGuardian-In-App-Agent"
387+
388+ @patch ("gg_api_core.utils.get_http_headers" )
389+ def test_returns_none_when_no_user_agent (self , mock_get_headers ):
390+ """
391+ GIVEN an HTTP request without a User-Agent header
392+ WHEN _get_caller_user_agent is called
393+ THEN it returns None
394+ """
395+ mock_get_headers .return_value = {"authorization" : "Bearer token" }
396+
397+ result = _get_caller_user_agent ()
398+
399+ assert result is None
400+
401+ @patch ("gg_api_core.utils.get_http_headers" )
402+ def test_returns_none_when_no_http_context (self , mock_get_headers ):
403+ """
404+ GIVEN no active HTTP request (e.g. stdio transport)
405+ WHEN _get_caller_user_agent is called
406+ THEN it returns None
407+ """
408+ mock_get_headers .return_value = {}
409+
410+ result = _get_caller_user_agent ()
411+
412+ assert result is None
413+
414+ @patch ("gg_api_core.utils.get_http_headers" )
415+ def test_returns_none_on_exception (self , mock_get_headers ):
416+ """
417+ GIVEN get_http_headers raises an exception
418+ WHEN _get_caller_user_agent is called
419+ THEN it returns None (graceful degradation)
420+ """
421+ mock_get_headers .side_effect = RuntimeError ("Unexpected error" )
422+
423+ result = _get_caller_user_agent ()
424+
425+ assert result is None
426+
427+ @patch ("gg_api_core.utils.get_http_headers" )
428+ @patch ("gg_api_core.utils.GitGuardianClient" )
429+ async def test_get_client_auto_extracts_user_agent (self , mock_client_class , mock_get_headers ):
430+ """
431+ GIVEN an HTTP request with User-Agent header
432+ WHEN get_client() is called without explicit user_agent (as tool handlers do)
433+ THEN the caller's User-Agent is automatically forwarded to GitGuardianClient
434+ """
435+ mock_get_headers .return_value = {
436+ "authorization" : "Bearer request-token" ,
437+ "user-agent" : "GitGuardian-In-App-Agent" ,
438+ }
439+ mock_client_class .return_value = MagicMock ()
440+
441+ with patch .dict (os .environ , {"MULTI_TENANCY_ENABLED" : "true" , "MCP_PORT" : "8080" }, clear = True ):
442+ await get_client ()
443+
444+ mock_client_class .assert_called_once_with (
445+ personal_access_token = "request-token" ,
446+ user_agent = "GitGuardian-In-App-Agent" ,
447+ )
448+
449+ @patch ("gg_api_core.utils.get_http_headers" )
450+ @patch ("gg_api_core.utils.GitGuardianClient" )
451+ async def test_explicit_user_agent_takes_precedence (self , mock_client_class , mock_get_headers ):
452+ """
453+ GIVEN an HTTP request with User-Agent header
454+ WHEN get_client() is called with an explicit user_agent
455+ THEN the explicit user_agent is used, not the one from headers
456+ """
457+ mock_get_headers .return_value = {"user-agent" : "GitGuardian-In-App-Agent" }
458+ mock_client_class .return_value = MagicMock ()
459+
460+ await get_client (personal_access_token = "explicit-token" , user_agent = "Custom-Agent" )
461+
462+ mock_client_class .assert_called_once_with (
463+ personal_access_token = "explicit-token" ,
464+ user_agent = "Custom-Agent" ,
465+ )
0 commit comments