test_web_routes.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. """Tests for web.routes helpers and endpoint guards."""
  2. import sys
  3. import os
  4. import threading
  5. import time
  6. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  7. from types import SimpleNamespace
  8. from unittest.mock import MagicMock
  9. import pytest
  10. from fastapi import HTTPException
  11. from fastapi.responses import JSONResponse
  12. from pydantic import ValidationError
  13. import web.routes as routes
  14. import web.state as _web_state_module
  15. from web.routes import (
  16. _get_state,
  17. _resolve_panorama_path,
  18. AddPointPayload,
  19. api_panorama,
  20. api_start_scan,
  21. api_live,
  22. )
  23. @pytest.fixture(autouse=True)
  24. def reset_web_state(monkeypatch):
  25. """Isolate the module-level web_state between tests."""
  26. monkeypatch.setattr(_web_state_module, "web_state", None)
  27. def test_get_state_raises_503_when_not_initialized():
  28. _web_state_module.web_state = None
  29. with pytest.raises(HTTPException) as exc_info:
  30. _get_state()
  31. assert exc_info.value.status_code == 503
  32. assert "not initialized" in exc_info.value.detail.lower()
  33. def test_resolve_panorama_path_returns_valid_path(tmp_path, monkeypatch):
  34. base = tmp_path / "panorama_data"
  35. (base / "panorama").mkdir(parents=True)
  36. img = base / "panorama" / "scan.jpg"
  37. img.write_bytes(b"")
  38. monkeypatch.setattr(routes, "PANORAMA_BASE", base.resolve())
  39. resolved = _resolve_panorama_path("panorama/scan.jpg")
  40. assert resolved == img.resolve()
  41. def test_resolve_panorama_path_rejects_escape(tmp_path, monkeypatch):
  42. base = tmp_path / "panorama_data"
  43. base.mkdir()
  44. monkeypatch.setattr(routes, "PANORAMA_BASE", base.resolve())
  45. with pytest.raises(HTTPException) as exc_info:
  46. _resolve_panorama_path("../evil.jpg")
  47. assert exc_info.value.status_code == 400
  48. assert "Invalid panorama path" in exc_info.value.detail
  49. def test_api_panorama_rejects_traversal_from_store(tmp_path, monkeypatch):
  50. base = tmp_path / "data"
  51. base.mkdir()
  52. monkeypatch.setattr(routes, "PANORAMA_BASE", base.resolve())
  53. scan_store = SimpleNamespace(
  54. get_group=lambda _gid: {"panorama": {"equirectangular": "../secret.txt"}}
  55. )
  56. _web_state_module.web_state = SimpleNamespace(
  57. scan_store=scan_store,
  58. group_state=None,
  59. scanners={},
  60. schedulers={},
  61. stream_manager=None,
  62. )
  63. with pytest.raises(HTTPException) as exc_info:
  64. api_panorama("g1")
  65. assert exc_info.value.status_code == 400
  66. def test_start_scan_returns_202_and_launches_thread():
  67. started = threading.Event()
  68. scanner = MagicMock()
  69. def run_with_event(*args, **kwargs):
  70. started.set()
  71. return {
  72. "samples": [],
  73. "panorama_path": None,
  74. "config": {},
  75. }
  76. scanner.run.side_effect = run_with_event
  77. group_data = {"polling_state": "idle"}
  78. class MockGroupState:
  79. def get(self, _gid):
  80. return dict(group_data)
  81. def update(self, _gid, key, value):
  82. group_data[key] = value
  83. def compare_and_update(self, _gid, key, expected, new_value):
  84. if group_data.get(key) == expected:
  85. group_data[key] = new_value
  86. return True
  87. return False
  88. state = SimpleNamespace(
  89. group_state=MockGroupState(),
  90. scan_store=SimpleNamespace(
  91. set_samples=lambda *_args, **_kwargs: None,
  92. set_panorama=lambda *_args, **_kwargs: None,
  93. set_scan_config=lambda *_args, **_kwargs: None,
  94. ),
  95. scanners={"g1": scanner},
  96. schedulers={},
  97. stream_manager=None,
  98. )
  99. _web_state_module.web_state = state
  100. resp = api_start_scan("g1")
  101. assert isinstance(resp, JSONResponse)
  102. assert resp.status_code == 202
  103. assert resp.body is not None
  104. assert b"Scan started" in resp.body
  105. deadline = time.time() + 1.0
  106. while time.time() < deadline and not started.is_set():
  107. time.sleep(0.01)
  108. started.wait(timeout=0)
  109. scanner.run.assert_called_once()
  110. def test_start_scan_rejects_concurrent_scan():
  111. group_data = {"polling_state": "scanning"}
  112. class MockGroupState:
  113. def get(self, _gid):
  114. return dict(group_data)
  115. def compare_and_update(self, _gid, key, expected, new_value):
  116. if group_data.get(key) == expected:
  117. group_data[key] = new_value
  118. return True
  119. return False
  120. state = SimpleNamespace(
  121. group_state=MockGroupState(),
  122. scanners={"g1": None},
  123. schedulers={},
  124. stream_manager=None,
  125. scan_store=None,
  126. )
  127. _web_state_module.web_state = state
  128. with pytest.raises(HTTPException) as exc_info:
  129. api_start_scan("g1")
  130. assert exc_info.value.status_code == 409
  131. assert "already in progress" in exc_info.value.detail.lower()
  132. def test_start_scan_returns_404_when_group_not_found():
  133. class MockGroupState:
  134. def get(self, _gid):
  135. return {}
  136. def compare_and_update(self, _gid, _key, _expected, _new_value):
  137. return False
  138. state = SimpleNamespace(
  139. group_state=MockGroupState(),
  140. scanners={},
  141. schedulers={},
  142. stream_manager=None,
  143. scan_store=None,
  144. )
  145. _web_state_module.web_state = state
  146. with pytest.raises(HTTPException) as exc_info:
  147. api_start_scan("missing")
  148. assert exc_info.value.status_code == 404
  149. assert "Group not found" in exc_info.value.detail
  150. def test_api_live_rejects_invalid_camera_type():
  151. _web_state_module.web_state = SimpleNamespace(stream_manager=MagicMock())
  152. with pytest.raises(HTTPException) as exc_info:
  153. api_live("front_door", "g1")
  154. assert exc_info.value.status_code == 400
  155. assert "Invalid camera type" in exc_info.value.detail
  156. @pytest.mark.parametrize(
  157. "payload, should_raise",
  158. [
  159. ({"pan": 10, "tilt": 5}, False),
  160. ({"pan": 400, "tilt": 0}, True),
  161. ({"pan": 0, "tilt": 100}, True),
  162. ({"pan": 0, "tilt": 0, "zoom": 0}, True),
  163. ({"pan": 0, "tilt": 0, "dwell_time": -1}, True),
  164. ],
  165. )
  166. def test_add_point_payload_validation(payload, should_raise):
  167. if should_raise:
  168. with pytest.raises(ValidationError):
  169. AddPointPayload(**payload)
  170. else:
  171. model = AddPointPayload(**payload)
  172. assert model.pan == payload["pan"]
  173. assert model.tilt == payload["tilt"]