99
1010# Standard
1111from contextlib import suppress
12+ import time
1213from urllib .parse import urlparse
1314import uuid
1415
@@ -172,36 +173,39 @@ def test_websocket_auth_handshake_behavior(self):
172173 assert "Parse error" in response or "jsonrpc" in response
173174
174175
176+ @pytest .fixture
177+ def api_token_info (admin_api : APIRequestContext ) -> dict :
178+ """Create an API token and return its access_token and token_id, then clean up."""
179+ resp = admin_api .post ("/tokens" , data = {"name" : f"last-used-test-{ uuid .uuid4 ().hex [:8 ]} " , "expires_in_days" : 1 })
180+ if resp .status == 404 :
181+ pytest .skip ("/tokens endpoint unavailable" )
182+ assert resp .status in (200 , 201 ), f"Failed to create token: { resp .status } { resp .text ()} "
183+ payload = resp .json ()
184+ token_obj = payload .get ("token" , payload )
185+ info = {
186+ "access_token" : payload ["access_token" ],
187+ "token_id" : token_obj .get ("id" ) or token_obj .get ("token_id" ),
188+ }
189+ yield info
190+ with suppress (Exception ):
191+ admin_api .delete (f"/tokens/{ info ['token_id' ]} " )
192+
193+
175194class TestApiTokenLastUsedViaMCP :
176195 """Verify API token last_used is updated when accessing virtual servers via MCP Streamable HTTP."""
177196
178- @pytest .fixture (autouse = True )
179- def _api_token (self , admin_api : APIRequestContext , playwright : Playwright ):
180- """Create an API token via session JWT and expose its access_token and id."""
181- # admin_api uses a session JWT, which CAN create tokens
182- resp = admin_api .post ("/tokens" , data = {"name" : f"last-used-test-{ uuid .uuid4 ().hex [:8 ]} " , "expires_in_days" : 1 })
183- if resp .status == 404 :
184- pytest .skip ("/tokens endpoint unavailable" )
185- assert resp .status in (200 , 201 ), f"Failed to create token: { resp .status } { resp .text ()} "
186- payload = resp .json ()
187- self ._access_token = payload ["access_token" ]
188- token_obj = payload .get ("token" , payload )
189- self ._token_id = token_obj .get ("id" ) or token_obj .get ("token_id" )
190- yield
191- # cleanup: revoke the token
192- with suppress (Exception ):
193- admin_api .delete (f"/tokens/{ self ._token_id } " )
194-
195- def test_mcp_streamable_http_updates_last_used (self , admin_api : APIRequestContext , playwright : Playwright , public_server_id : str ):
197+ def test_mcp_streamable_http_updates_last_used (self , admin_api : APIRequestContext , playwright : Playwright , public_server_id : str , api_token_info : dict ):
196198 """Accessing /servers/{id}/mcp with an API token should update last_used."""
199+ token_id = api_token_info ["token_id" ]
200+
197201 # 1. Check initial last_used (should be None for new token)
198- detail = admin_api .get (f"/tokens/{ self . _token_id } " )
202+ detail = admin_api .get (f"/tokens/{ token_id } " )
199203 if detail .status == 404 :
200204 pytest .skip ("Token detail endpoint unavailable" )
201205 initial_last_used = detail .json ().get ("last_used" )
202206
203207 # 2. Make MCP Streamable HTTP request with the API token
204- token_ctx = _api_context (playwright , self . _access_token )
208+ token_ctx = _api_context (playwright , api_token_info [ "access_token" ] )
205209 try :
206210 mcp_resp = token_ctx .post (
207211 f"/servers/{ public_server_id } /mcp" ,
@@ -216,34 +220,55 @@ def test_mcp_streamable_http_updates_last_used(self, admin_api: APIRequestContex
216220 assert mcp_resp .status != 401 , f"API token auth rejected: { mcp_resp .text ()} "
217221
218222 # 3. Verify last_used was updated
219- # Standard
220- import time
221-
222- time .sleep (2 ) # Allow async update to complete
223- detail2 = admin_api .get (f"/tokens/{ self ._token_id } " )
223+ time .sleep (1 ) # Allow propagation across multi-gateway setup
224+ detail2 = admin_api .get (f"/tokens/{ token_id } " )
224225 updated_last_used = detail2 .json ().get ("last_used" )
225226
226227 assert updated_last_used is not None , f"last_used not updated after MCP access. Initial: { initial_last_used } , After: { updated_last_used } "
227228
228- def test_mcp_request_records_token_usage_log (self , admin_api : APIRequestContext , playwright : Playwright , public_server_id : str ):
229- """MCP requests with API tokens should appear in the token usage log."""
230- token_ctx = _api_context (playwright , self ._access_token )
229+ def test_mcp_requests_accumulate_in_token_usage_stats (self , admin_api : APIRequestContext , playwright : Playwright , public_server_id : str , api_token_info : dict ):
230+ """Multiple MCP requests with an API token should be accurately reflected in usage statistics.
231+
232+ Uses a fresh per-test token (api_token_info fixture) so stats are isolated — no other
233+ requests can contribute to this token's usage counters.
234+ """
235+ token_id = api_token_info ["token_id" ]
236+ num_requests = 5
237+
238+ # 1. Verify fresh token has zero usage
239+ usage_resp = admin_api .get (f"/tokens/{ token_id } /usage" )
240+ if usage_resp .status == 404 :
241+ pytest .skip ("Token usage endpoint unavailable" )
242+ baseline = usage_resp .json ()
243+ assert baseline .get ("total_requests" , 0 ) == 0 , "Fresh token should have zero usage"
244+
245+ # 2. Make exactly N MCP requests
246+ token_ctx = _api_context (playwright , api_token_info ["access_token" ])
231247 try :
232- token_ctx .post (
233- f"/servers/{ public_server_id } /mcp" ,
234- data = {"jsonrpc" : "2.0" , "id" : "2" , "method" : "ping" , "params" : {}},
235- headers = {"Content-Type" : "application/json" , "Accept" : "application/json, text/event-stream" },
236- )
248+ for i in range (num_requests ):
249+ token_ctx .post (
250+ f"/servers/{ public_server_id } /mcp" ,
251+ data = {"jsonrpc" : "2.0" , "id" : str (i + 1 ), "method" : "ping" , "params" : {}},
252+ headers = {"Content-Type" : "application/json" , "Accept" : "application/json, text/event-stream" },
253+ )
237254 finally :
238255 token_ctx .dispose ()
239256
240- # Standard
241- import time
257+ # 3. Allow async logging to complete
258+ time . sleep ( 1 )
242259
243- time .sleep (2 )
244- usage_resp = admin_api .get (f"/tokens/{ self ._token_id } /usage" )
245- if usage_resp .status == 404 :
246- pytest .skip ("Token usage endpoint unavailable" )
247- assert usage_resp .status == 200 , f"Usage stats failed: { usage_resp .status } "
248- total = usage_resp .json ().get ("total_requests" , 0 )
249- assert total > 0 , f"Token usage log should have entries after MCP access, got { total } "
260+ # 4. Verify usage stats with strict equality (isolated per-test token)
261+ usage_resp2 = admin_api .get (f"/tokens/{ token_id } /usage" )
262+ assert usage_resp2 .status == 200
263+ stats = usage_resp2 .json ()
264+
265+ assert stats ["total_requests" ] == num_requests , f"Expected exactly { num_requests } total requests, got { stats ['total_requests' ]} "
266+ assert stats ["successful_requests" ] == num_requests , f"Expected exactly { num_requests } successful requests, got { stats ['successful_requests' ]} "
267+ assert stats ["blocked_requests" ] == 0 , f"Expected 0 blocked requests, got { stats ['blocked_requests' ]} "
268+ assert stats ["success_rate" ] == 1.0 , f"Expected 100% success rate, got { stats ['success_rate' ]} "
269+ assert stats ["average_response_time_ms" ] > 0 , f"Expected positive average response time, got { stats ['average_response_time_ms' ]} ms"
270+
271+ # Top endpoints should include the MCP server path
272+ endpoint_paths = [ep [0 ] if isinstance (ep , (list , tuple )) else ep for ep in stats .get ("top_endpoints" , [])]
273+ has_mcp_endpoint = any ("/mcp" in path for path in endpoint_paths )
274+ assert has_mcp_endpoint , f"Expected /mcp in top_endpoints, got { endpoint_paths } "
0 commit comments