Skip to content

Commit f63c887

Browse files
committed
test(auth): add E2E tests for API token last_used and usage stats via MCP transport
Signed-off-by: kimsehwan96 <sktpghks138@gmail.com>
1 parent f84be25 commit f63c887

1 file changed

Lines changed: 67 additions & 42 deletions

File tree

tests/playwright/security/test_mcp_transport_auth_matrix.py

Lines changed: 67 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
# Standard
1111
from contextlib import suppress
12+
import time
1213
from urllib.parse import urlparse
1314
import 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+
175194
class 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

Comments
 (0)