99from pathlib import Path
1010import re
1111import signal
12+ import socket
1213import sys
1314import threading
1415import time
1516from typing import Any
17+ from urllib .parse import urlencode , urlsplit
1618
1719import httpx
1820import pytest
1921
22+ import agentveil_mcp_proxy .approval .server as approval_server_module
2023import agentveil_mcp_proxy .cli as proxy_cli
2124from agentveil_mcp_proxy .approval import (
2225 ApprovalFlowError ,
@@ -182,6 +185,14 @@ def _get_csrf(client: httpx.Client, url: str) -> str:
182185 return match .group (1 )
183186
184187
188+ def _get_csrf_and_cookie (client : httpx .Client , url : str ) -> tuple [str , str ]:
189+ response = client .get (url )
190+ assert response .status_code == 200
191+ match = TOKEN_RE .search (response .text )
192+ assert match
193+ return match .group (1 ), response .headers ["Set-Cookie" ].split (";" , 1 )[0 ]
194+
195+
185196def _post_decision (client : httpx .Client , url : str , * , decision : str , csrf : str , scope : str = "exact" ):
186197 return client .post (url , data = {
187198 "decision" : decision ,
@@ -225,6 +236,48 @@ def _request_and_post(
225236 return result_box ["outcome" ], prompt , response
226237
227238
239+ def _raw_http_request (host : str , port : int , request : str , * , timeout : float = 2.0 ) -> bytes :
240+ with socket .create_connection ((host , port ), timeout = timeout ) as sock :
241+ sock .settimeout (timeout )
242+ sock .sendall (request .encode ("utf-8" ))
243+ chunks = []
244+ while True :
245+ chunk = sock .recv (4096 )
246+ if not chunk :
247+ break
248+ chunks .append (chunk )
249+ return b"" .join (chunks )
250+
251+
252+ def _raw_post (
253+ server : ApprovalServer ,
254+ url : str ,
255+ * ,
256+ content_length : str ,
257+ body : str = "" ,
258+ cookie : str | None = None ,
259+ ) -> bytes :
260+ path = urlsplit (url ).path
261+ headers = [
262+ f"POST { path } HTTP/1.1" ,
263+ f"Host: { server .host } :{ server .port } " ,
264+ "Content-Type: application/x-www-form-urlencoded" ,
265+ f"Content-Length: { content_length } " ,
266+ "Connection: close" ,
267+ ]
268+ if cookie is not None :
269+ headers .append (f"Cookie: { cookie } " )
270+ request = "\r \n " .join (headers ) + "\r \n \r \n " + body
271+ return _raw_http_request (server .host , server .port , request )
272+
273+
274+ def _assert_status (raw_response : bytes , status_code : int ) -> None :
275+ status_line = raw_response .split (b"\r \n " , 1 )[0 ].decode ("ascii" , errors = "replace" )
276+ assert status_line .startswith (f"HTTP/1.0 { status_code } " ) or status_line .startswith (
277+ f"HTTP/1.1 { status_code } "
278+ ), raw_response .decode ("utf-8" , errors = "replace" )
279+
280+
228281def test_approval_server_binds_only_to_127_0_0_1 ():
229282 server = ApprovalServer ()
230283 server .start ()
@@ -238,6 +291,99 @@ def test_approval_server_binds_only_to_127_0_0_1():
238291 ApprovalServer (host = "0.0.0.0" )
239292
240293
294+ def test_approval_server_request_threads_are_daemon (monkeypatch ):
295+ seen : dict [str , bool ] = {}
296+ original_do_get = approval_server_module ._ApprovalRequestHandler .do_GET
297+
298+ def recording_do_get (self ):
299+ seen ["daemon" ] = threading .current_thread ().daemon
300+ return original_do_get (self )
301+
302+ monkeypatch .setattr (approval_server_module ._ApprovalRequestHandler , "do_GET" , recording_do_get )
303+ server = ApprovalServer ()
304+ server .start ()
305+ try :
306+ assert server ._httpd is not None
307+ assert server ._httpd .daemon_threads is True
308+ url = server .register (_prompt ())
309+ response = httpx .get (url )
310+ assert response .status_code == 200
311+ assert seen ["daemon" ] is True
312+ finally :
313+ server .stop ()
314+
315+
316+ def _assert_invalid_content_length_rejected (content_length : str ) -> None :
317+ server = ApprovalServer ()
318+ server .start ()
319+ try :
320+ url = server .register (_prompt ())
321+ with httpx .Client () as client :
322+ _csrf , cookie = _get_csrf_and_cookie (client , url )
323+ response = _raw_post (server , url , content_length = content_length , cookie = cookie )
324+ _assert_status (response , 400 )
325+ assert b"invalid content length" in response
326+ finally :
327+ server .stop ()
328+
329+
330+ def test_post_with_non_numeric_content_length_returns_400 ():
331+ _assert_invalid_content_length_rejected ("abc" )
332+
333+
334+ def test_post_with_negative_content_length_returns_400 ():
335+ _assert_invalid_content_length_rejected ("-100" )
336+
337+
338+ def test_post_with_oversized_content_length_returns_400 ():
339+ _assert_invalid_content_length_rejected ("99999" )
340+
341+
342+ def test_post_with_valid_content_length_succeeds ():
343+ server = ApprovalServer ()
344+ server .start ()
345+ try :
346+ url = server .register (_prompt ())
347+ with httpx .Client () as client :
348+ csrf , cookie = _get_csrf_and_cookie (client , url )
349+ body = urlencode ({
350+ "decision" : "approve" ,
351+ "csrf_token" : csrf ,
352+ "approval_scope" : "exact" ,
353+ })
354+ response = _raw_post (
355+ server ,
356+ url ,
357+ content_length = str (len (body .encode ("utf-8" ))),
358+ body = body ,
359+ cookie = cookie ,
360+ )
361+ _assert_status (response , 200 )
362+ decision = server .wait_for_decision ("req-1" , timeout = 0.1 )
363+ assert decision is not None
364+ assert decision .decision == "approve"
365+ finally :
366+ server .stop ()
367+
368+
369+ def test_slow_client_request_socket_timeout (monkeypatch ):
370+ monkeypatch .setattr (approval_server_module , "REQUEST_SOCKET_TIMEOUT_SECONDS" , 0.25 )
371+ server = ApprovalServer ()
372+ server .start ()
373+ try :
374+ with socket .create_connection ((server .host , server .port ), timeout = 2.0 ) as sock :
375+ sock .settimeout (2.0 )
376+ sock .sendall (b"GET /approval/" )
377+ time .sleep (0.6 )
378+ try :
379+ data = sock .recv (1 )
380+ except (ConnectionResetError , TimeoutError , socket .timeout ):
381+ data = b""
382+ assert data == b""
383+ finally :
384+ server .stop ()
385+
386+
241387def test_post_without_token_returns_403 ():
242388 server = ApprovalServer ()
243389 server .start ()
0 commit comments