"""Tests for Web UI Chat Upgrade — SSE proxy, SSRF validation, param bounds.""" from unittest.mock import MagicMock, patch import pytest def _auth_headers(): """Return auth headers with the current UI token.""" from soup_cli.ui.app import get_auth_token return {"Authorization": f"Bearer {get_auth_token()}"} class TestChatEndpointExists: """Test /api/chat/send endpoint registration.""" def test_chat_send_endpoint_exists(self): """Route /api/chat/send should be registered.""" try: import fastapi # noqa: F401 except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app app = create_app() routes = [route.path for route in app.routes] assert "/api/chat/send" in routes class TestChatSSRFProtection: """Test SSRF protection on chat endpoint.""" def test_rejects_non_localhost_http(self): """Chat endpoint should reject non-localhost HTTP URLs.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) response = client.post( "/api/chat/send", json={ "messages": [{"role": "user", "content": "hi"}], "endpoint": "http://evil.com:8000", }, headers=_auth_headers(), ) assert response.status_code == 400 def test_allows_localhost_http(self): """Chat endpoint should allow localhost HTTP URLs.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) # Will fail at connection level but not at validation response = client.post( "/api/chat/send", json={ "messages": [{"role": "user", "content": "hi"}], "endpoint": "http://localhost:8000", }, headers=_auth_headers(), ) # Should not be 400 (SSRF rejection) — may be 502 or stream error assert response.status_code != 400 def test_allows_https_remote(self): """Chat endpoint should allow HTTPS remote URLs.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) response = client.post( "/api/chat/send", json={ "messages": [{"role": "user", "content": "hi"}], "endpoint": "https://api.example.com", }, headers=_auth_headers(), ) # Should not be 400 (SSRF rejection) assert response.status_code != 400 class TestChatParamBounds: """Test parameter validation on chat endpoint.""" def test_max_tokens_capped(self): """max_tokens should be capped at 16384.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) response = client.post( "/api/chat/send", json={ "messages": [{"role": "user", "content": "hi"}], "endpoint": "http://localhost:8000", "max_tokens": 99999, }, headers=_auth_headers(), ) # Should be rejected (exceeds cap) — 422 from Pydantic or 400 assert response.status_code in (400, 422) def test_temperature_bounded(self): """temperature should be bounded 0.0-2.0.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) response = client.post( "/api/chat/send", json={ "messages": [{"role": "user", "content": "hi"}], "endpoint": "http://localhost:8000", "temperature": 5.0, }, headers=_auth_headers(), ) assert response.status_code == 400 or response.status_code == 422 def test_top_p_bounded(self): """top_p should be bounded 0.0-1.0.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) response = client.post( "/api/chat/send", json={ "messages": [{"role": "user", "content": "hi"}], "endpoint": "http://localhost:8000", "top_p": 2.0, }, headers=_auth_headers(), ) assert response.status_code == 400 or response.status_code == 422 class TestChatInvalidScheme: """Test rejection of invalid URL schemes.""" def test_rejects_ftp_scheme(self): """Chat endpoint should reject ftp:// URLs.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) response = client.post( "/api/chat/send", json={ "messages": [{"role": "user", "content": "hi"}], "endpoint": "ftp://evil.com/model", }, headers=_auth_headers(), ) assert response.status_code == 400 def test_rejects_ipv6_mapped_loopback_alias(self): """Chat endpoint should reject IPv6-mapped private addresses.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) response = client.post( "/api/chat/send", json={ "messages": [{"role": "user", "content": "hi"}], "endpoint": "http://192.168.1.1:8000", }, headers=_auth_headers(), ) assert response.status_code == 400 def test_allows_127_loopback_range(self): """Chat endpoint should allow 127.x.x.x loopback addresses.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) response = client.post( "/api/chat/send", json={ "messages": [{"role": "user", "content": "hi"}], "endpoint": "http://127.0.0.2:8000", }, headers=_auth_headers(), ) # 127.0.0.2 is loopback — should NOT be rejected as SSRF assert response.status_code != 400 def test_rejects_file_scheme(self): """Chat endpoint should reject file:// URLs.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) response = client.post( "/api/chat/send", json={ "messages": [{"role": "user", "content": "hi"}], "endpoint": "file:///etc/passwd", }, headers=_auth_headers(), ) assert response.status_code == 400 class TestChatAuth: """Test chat endpoint auth requirements.""" def test_requires_auth(self): """Chat send (POST) should require Bearer auth.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) response = client.post( "/api/chat/send", json={ "messages": [{"role": "user", "content": "hi"}], "endpoint": "http://localhost:8000", }, ) assert response.status_code == 401 class TestChatValidation: """Test chat request validation.""" def test_empty_messages_rejected(self): """Empty messages array should return 400.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) response = client.post( "/api/chat/send", json={ "messages": [], "endpoint": "http://localhost:8000", }, headers=_auth_headers(), ) assert response.status_code == 400 def test_returns_event_stream(self): """Chat endpoint should return text/event-stream content type.""" try: from fastapi.testclient import TestClient except ImportError: pytest.skip("FastAPI not installed") from soup_cli.ui.app import create_app client = TestClient(create_app()) # Mock httpx to avoid real connection mock_response = MagicMock() mock_response.status_code = 200 mock_response.iter_lines.return_value = iter([ 'data: {"choices":[{"delta":{"content":"Hi"}}]}', 'data: [DONE]', ]) mock_response.__enter__ = lambda s: s mock_response.__exit__ = lambda s, *a: None with patch("httpx.stream", return_value=mock_response): with client.stream( "POST", "/api/chat/send", json={ "messages": [{"role": "user", "content": "hi"}], "endpoint": "http://localhost:8000", }, headers=_auth_headers(), ) as resp: assert resp.status_code == 200 assert "text/event-stream" in resp.headers["content-type"]