Add routing for spawn-related endpoints to HttpMixin: - GET /api/projects -> _handle_projects - GET /api/health -> _handle_health - POST /api/spawn -> _handle_spawn - POST /api/projects/refresh -> _handle_projects_refresh Update CORS preflight (AC-39) to include GET in allowed methods and Authorization in allowed headers. Closes bd-2al
378 lines
13 KiB
Python
378 lines
13 KiB
Python
"""Tests for mixins/http.py edge cases.
|
|
|
|
Unit tests for HTTP routing and response handling.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
from amc_server.mixins.http import HttpMixin
|
|
|
|
|
|
class DummyHttpHandler(HttpMixin):
|
|
"""Minimal handler for testing HTTP mixin."""
|
|
|
|
def __init__(self):
|
|
self.response_code = None
|
|
self.headers_sent = {}
|
|
self.body_sent = b""
|
|
self.path = "/"
|
|
self.wfile = io.BytesIO()
|
|
|
|
def send_response(self, code):
|
|
self.response_code = code
|
|
|
|
def send_header(self, key, value):
|
|
self.headers_sent[key] = value
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
|
|
class TestSendBytesResponse(unittest.TestCase):
|
|
"""Tests for _send_bytes_response edge cases."""
|
|
|
|
def test_sends_correct_headers(self):
|
|
handler = DummyHttpHandler()
|
|
handler._send_bytes_response(200, b"test", content_type="text/plain")
|
|
|
|
self.assertEqual(handler.response_code, 200)
|
|
self.assertEqual(handler.headers_sent["Content-Type"], "text/plain")
|
|
self.assertEqual(handler.headers_sent["Content-Length"], "4")
|
|
|
|
def test_includes_extra_headers(self):
|
|
handler = DummyHttpHandler()
|
|
handler._send_bytes_response(
|
|
200, b"test",
|
|
extra_headers={"X-Custom": "value", "Cache-Control": "no-cache"}
|
|
)
|
|
|
|
self.assertEqual(handler.headers_sent["X-Custom"], "value")
|
|
self.assertEqual(handler.headers_sent["Cache-Control"], "no-cache")
|
|
|
|
def test_broken_pipe_returns_false(self):
|
|
handler = DummyHttpHandler()
|
|
handler.wfile.write = MagicMock(side_effect=BrokenPipeError())
|
|
|
|
result = handler._send_bytes_response(200, b"test")
|
|
self.assertFalse(result)
|
|
|
|
def test_connection_reset_returns_false(self):
|
|
handler = DummyHttpHandler()
|
|
handler.wfile.write = MagicMock(side_effect=ConnectionResetError())
|
|
|
|
result = handler._send_bytes_response(200, b"test")
|
|
self.assertFalse(result)
|
|
|
|
def test_os_error_returns_false(self):
|
|
handler = DummyHttpHandler()
|
|
handler.wfile.write = MagicMock(side_effect=OSError("write error"))
|
|
|
|
result = handler._send_bytes_response(200, b"test")
|
|
self.assertFalse(result)
|
|
|
|
|
|
class TestSendJson(unittest.TestCase):
|
|
"""Tests for _send_json edge cases."""
|
|
|
|
def test_includes_cors_header(self):
|
|
handler = DummyHttpHandler()
|
|
handler._send_json(200, {"key": "value"})
|
|
|
|
self.assertEqual(handler.headers_sent["Access-Control-Allow-Origin"], "*")
|
|
|
|
def test_sets_json_content_type(self):
|
|
handler = DummyHttpHandler()
|
|
handler._send_json(200, {"key": "value"})
|
|
|
|
self.assertEqual(handler.headers_sent["Content-Type"], "application/json")
|
|
|
|
def test_encodes_payload_as_json(self):
|
|
handler = DummyHttpHandler()
|
|
handler._send_json(200, {"key": "value"})
|
|
|
|
written = handler.wfile.getvalue()
|
|
self.assertEqual(json.loads(written), {"key": "value"})
|
|
|
|
|
|
class TestServeDashboardFile(unittest.TestCase):
|
|
"""Tests for _serve_dashboard_file edge cases."""
|
|
|
|
def test_nonexistent_file_returns_404(self):
|
|
handler = DummyHttpHandler()
|
|
handler.errors = []
|
|
|
|
def capture_error(code, message):
|
|
handler.errors.append((code, message))
|
|
|
|
handler._json_error = capture_error
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
|
handler._serve_dashboard_file("nonexistent.html")
|
|
|
|
self.assertEqual(len(handler.errors), 1)
|
|
self.assertEqual(handler.errors[0][0], 404)
|
|
|
|
def test_path_traversal_blocked(self):
|
|
handler = DummyHttpHandler()
|
|
handler.errors = []
|
|
|
|
def capture_error(code, message):
|
|
handler.errors.append((code, message))
|
|
|
|
handler._json_error = capture_error
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
# Create a file outside the dashboard dir that shouldn't be accessible
|
|
secret = Path(tmpdir).parent / "secret.txt"
|
|
|
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
|
handler._serve_dashboard_file("../secret.txt")
|
|
|
|
self.assertEqual(len(handler.errors), 1)
|
|
self.assertEqual(handler.errors[0][0], 403)
|
|
|
|
def test_correct_content_type_for_html(self):
|
|
handler = DummyHttpHandler()
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
html_file = Path(tmpdir) / "test.html"
|
|
html_file.write_text("<html></html>")
|
|
|
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
|
handler._serve_dashboard_file("test.html")
|
|
|
|
self.assertEqual(handler.headers_sent["Content-Type"], "text/html; charset=utf-8")
|
|
|
|
def test_correct_content_type_for_css(self):
|
|
handler = DummyHttpHandler()
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
css_file = Path(tmpdir) / "styles.css"
|
|
css_file.write_text("body {}")
|
|
|
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
|
handler._serve_dashboard_file("styles.css")
|
|
|
|
self.assertEqual(handler.headers_sent["Content-Type"], "text/css; charset=utf-8")
|
|
|
|
def test_correct_content_type_for_js(self):
|
|
handler = DummyHttpHandler()
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
js_file = Path(tmpdir) / "app.js"
|
|
js_file.write_text("console.log('hello')")
|
|
|
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
|
handler._serve_dashboard_file("app.js")
|
|
|
|
self.assertEqual(handler.headers_sent["Content-Type"], "application/javascript; charset=utf-8")
|
|
|
|
def test_unknown_extension_gets_octet_stream(self):
|
|
handler = DummyHttpHandler()
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
unknown_file = Path(tmpdir) / "data.xyz"
|
|
unknown_file.write_bytes(b"\x00\x01\x02")
|
|
|
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
|
handler._serve_dashboard_file("data.xyz")
|
|
|
|
self.assertEqual(handler.headers_sent["Content-Type"], "application/octet-stream")
|
|
|
|
def test_no_cache_headers_set(self):
|
|
handler = DummyHttpHandler()
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
html_file = Path(tmpdir) / "test.html"
|
|
html_file.write_text("<html></html>")
|
|
|
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
|
handler._serve_dashboard_file("test.html")
|
|
|
|
self.assertIn("no-cache", handler.headers_sent.get("Cache-Control", ""))
|
|
|
|
|
|
class TestDoGet(unittest.TestCase):
|
|
"""Tests for do_GET routing edge cases."""
|
|
|
|
def _make_handler(self, path):
|
|
handler = DummyHttpHandler()
|
|
handler.path = path
|
|
handler._serve_dashboard_file = MagicMock()
|
|
handler._serve_state = MagicMock()
|
|
handler._serve_stream = MagicMock()
|
|
handler._serve_events = MagicMock()
|
|
handler._serve_conversation = MagicMock()
|
|
handler._json_error = MagicMock()
|
|
return handler
|
|
|
|
def test_root_serves_index(self):
|
|
handler = self._make_handler("/")
|
|
handler.do_GET()
|
|
handler._serve_dashboard_file.assert_called_with("index.html")
|
|
|
|
def test_index_html_serves_index(self):
|
|
handler = self._make_handler("/index.html")
|
|
handler.do_GET()
|
|
handler._serve_dashboard_file.assert_called_with("index.html")
|
|
|
|
def test_static_file_served(self):
|
|
handler = self._make_handler("/components/App.js")
|
|
handler.do_GET()
|
|
handler._serve_dashboard_file.assert_called_with("components/App.js")
|
|
|
|
def test_path_traversal_in_static_blocked(self):
|
|
handler = self._make_handler("/../../etc/passwd")
|
|
handler.do_GET()
|
|
handler._json_error.assert_called_with(404, "Not Found")
|
|
|
|
def test_api_state_routed(self):
|
|
handler = self._make_handler("/api/state")
|
|
handler.do_GET()
|
|
handler._serve_state.assert_called_once()
|
|
|
|
def test_api_stream_routed(self):
|
|
handler = self._make_handler("/api/stream")
|
|
handler.do_GET()
|
|
handler._serve_stream.assert_called_once()
|
|
|
|
def test_api_events_routed_with_id(self):
|
|
handler = self._make_handler("/api/events/session-123")
|
|
handler.do_GET()
|
|
handler._serve_events.assert_called_with("session-123")
|
|
|
|
def test_api_events_url_decoded(self):
|
|
handler = self._make_handler("/api/events/session%20with%20spaces")
|
|
handler.do_GET()
|
|
handler._serve_events.assert_called_with("session with spaces")
|
|
|
|
def test_api_conversation_with_query_params(self):
|
|
handler = self._make_handler("/api/conversation/sess123?project_dir=/test&agent=codex")
|
|
handler.do_GET()
|
|
handler._serve_conversation.assert_called_with("sess123", "/test", "codex")
|
|
|
|
def test_api_conversation_defaults_to_claude(self):
|
|
handler = self._make_handler("/api/conversation/sess123")
|
|
handler.do_GET()
|
|
handler._serve_conversation.assert_called_with("sess123", "", "claude")
|
|
|
|
def test_unknown_api_path_returns_404(self):
|
|
handler = self._make_handler("/api/unknown")
|
|
handler.do_GET()
|
|
handler._json_error.assert_called_with(404, "Not Found")
|
|
|
|
|
|
class TestDoGetSpawnRoutes(unittest.TestCase):
|
|
"""Tests for spawn-related GET routes."""
|
|
|
|
def _make_handler(self, path):
|
|
handler = DummyHttpHandler()
|
|
handler.path = path
|
|
handler._serve_dashboard_file = MagicMock()
|
|
handler._serve_state = MagicMock()
|
|
handler._serve_stream = MagicMock()
|
|
handler._serve_events = MagicMock()
|
|
handler._serve_conversation = MagicMock()
|
|
handler._serve_skills = MagicMock()
|
|
handler._handle_projects = MagicMock()
|
|
handler._handle_health = MagicMock()
|
|
handler._json_error = MagicMock()
|
|
return handler
|
|
|
|
def test_api_projects_routed(self):
|
|
handler = self._make_handler("/api/projects")
|
|
handler.do_GET()
|
|
handler._handle_projects.assert_called_once()
|
|
|
|
def test_api_health_routed(self):
|
|
handler = self._make_handler("/api/health")
|
|
handler.do_GET()
|
|
handler._handle_health.assert_called_once()
|
|
|
|
|
|
class TestDoPost(unittest.TestCase):
|
|
"""Tests for do_POST routing edge cases."""
|
|
|
|
def _make_handler(self, path):
|
|
handler = DummyHttpHandler()
|
|
handler.path = path
|
|
handler._dismiss_dead_sessions = MagicMock()
|
|
handler._dismiss_session = MagicMock()
|
|
handler._respond_to_session = MagicMock()
|
|
handler._handle_spawn = MagicMock()
|
|
handler._handle_projects_refresh = MagicMock()
|
|
handler._json_error = MagicMock()
|
|
return handler
|
|
|
|
def test_dismiss_dead_routed(self):
|
|
handler = self._make_handler("/api/dismiss-dead")
|
|
handler.do_POST()
|
|
handler._dismiss_dead_sessions.assert_called_once()
|
|
|
|
def test_dismiss_session_routed(self):
|
|
handler = self._make_handler("/api/dismiss/session-abc")
|
|
handler.do_POST()
|
|
handler._dismiss_session.assert_called_with("session-abc")
|
|
|
|
def test_dismiss_url_decoded(self):
|
|
handler = self._make_handler("/api/dismiss/session%2Fwith%2Fslash")
|
|
handler.do_POST()
|
|
handler._dismiss_session.assert_called_with("session/with/slash")
|
|
|
|
def test_respond_routed(self):
|
|
handler = self._make_handler("/api/respond/session-xyz")
|
|
handler.do_POST()
|
|
handler._respond_to_session.assert_called_with("session-xyz")
|
|
|
|
def test_spawn_routed(self):
|
|
handler = self._make_handler("/api/spawn")
|
|
handler.do_POST()
|
|
handler._handle_spawn.assert_called_once()
|
|
|
|
def test_projects_refresh_routed(self):
|
|
handler = self._make_handler("/api/projects/refresh")
|
|
handler.do_POST()
|
|
handler._handle_projects_refresh.assert_called_once()
|
|
|
|
def test_unknown_post_path_returns_404(self):
|
|
handler = self._make_handler("/api/unknown")
|
|
handler.do_POST()
|
|
handler._json_error.assert_called_with(404, "Not Found")
|
|
|
|
|
|
class TestDoOptions(unittest.TestCase):
|
|
"""Tests for do_OPTIONS CORS preflight."""
|
|
|
|
def test_returns_204_with_cors_headers(self):
|
|
handler = DummyHttpHandler()
|
|
handler.do_OPTIONS()
|
|
|
|
self.assertEqual(handler.response_code, 204)
|
|
self.assertEqual(handler.headers_sent["Access-Control-Allow-Origin"], "*")
|
|
self.assertIn("POST", handler.headers_sent["Access-Control-Allow-Methods"])
|
|
self.assertIn("GET", handler.headers_sent["Access-Control-Allow-Methods"])
|
|
self.assertIn("Content-Type", handler.headers_sent["Access-Control-Allow-Headers"])
|
|
self.assertIn("Authorization", handler.headers_sent["Access-Control-Allow-Headers"])
|
|
|
|
|
|
class TestJsonError(unittest.TestCase):
|
|
"""Tests for _json_error helper."""
|
|
|
|
def test_sends_json_with_error(self):
|
|
handler = DummyHttpHandler()
|
|
handler._json_error(404, "Not Found")
|
|
|
|
written = handler.wfile.getvalue()
|
|
payload = json.loads(written)
|
|
self.assertEqual(payload, {"ok": False, "error": "Not Found"})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|