test_vp_diagnostic.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. """Unit tests for the virtual printer setup diagnostic."""
  2. import tempfile
  3. from pathlib import Path
  4. from types import SimpleNamespace
  5. from unittest.mock import AsyncMock, mock_open, patch
  6. import pytest
  7. from backend.app.services.virtual_printer.certificate import CertificateService
  8. from backend.app.services.virtual_printer.diagnostic import (
  9. can_bind_privileged_ports,
  10. run_vp_diagnostic,
  11. )
  12. _DIAG = "backend.app.services.virtual_printer.diagnostic._check_port"
  13. _FIND_IFACE = "backend.app.services.network_utils.find_interface_for_ip"
  14. def _vp(**overrides):
  15. """A virtual-printer DB row stand-in with sensible healthy defaults."""
  16. base = {
  17. "id": 1,
  18. "name": "Test VP",
  19. "mode": "archive",
  20. "enabled": True,
  21. "bind_ip": "192.168.1.50",
  22. "access_code": "12345678",
  23. "target_printer_id": None,
  24. }
  25. base.update(overrides)
  26. return SimpleNamespace(**base)
  27. class _FakeInstance:
  28. """Minimal VirtualPrinterInstance stand-in for the diagnostic."""
  29. def __init__(self, running=True, cert_exists=True, proxy_status=None):
  30. self.is_running = running
  31. self._cert_exists = cert_exists
  32. self._proxy_status = proxy_status
  33. @property
  34. def cert_path(self):
  35. return SimpleNamespace(exists=lambda: self._cert_exists)
  36. def get_status(self):
  37. return {"proxy": self._proxy_status} if self._proxy_status is not None else {}
  38. def _checks(result):
  39. return {c.id: c.status for c in result.checks}
  40. class TestRunVpDiagnostic:
  41. @pytest.mark.asyncio
  42. async def test_disabled_vp_reports_problems(self):
  43. """A disabled VP fails the 'enabled' check; running/port checks skip."""
  44. result = await run_vp_diagnostic(_vp(enabled=False, bind_ip=None, access_code=None), None)
  45. c = _checks(result)
  46. assert result.overall == "problems"
  47. assert c["enabled"] == "fail"
  48. assert c["running"] == "skip"
  49. assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "skip"
  50. assert c["certificate"] == "skip"
  51. @pytest.mark.asyncio
  52. async def test_running_server_vp_all_pass(self):
  53. """Enabled + running + every port listening + cert present → overall ok."""
  54. with (
  55. patch(_DIAG, AsyncMock(return_value=True)),
  56. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  57. ):
  58. result = await run_vp_diagnostic(_vp(), _FakeInstance())
  59. c = _checks(result)
  60. assert result.overall == "ok"
  61. assert c["enabled"] == "pass"
  62. assert c["running"] == "pass"
  63. assert c["bind_interface"] == "pass"
  64. assert c["access_code"] == "pass"
  65. assert c["target_printer"] == "skip" # not proxy mode
  66. assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "pass"
  67. assert c["certificate"] == "pass"
  68. @pytest.mark.asyncio
  69. async def test_port_not_listening_is_a_problem(self):
  70. """A service object can exist while its socket never bound — the probe
  71. is what catches it, so a dead port must surface as a failure."""
  72. with (
  73. patch(_DIAG, AsyncMock(return_value=False)),
  74. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  75. ):
  76. result = await run_vp_diagnostic(_vp(), _FakeInstance())
  77. c = _checks(result)
  78. assert result.overall == "problems"
  79. assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "fail"
  80. @pytest.mark.asyncio
  81. async def test_stale_bind_ip_fails_interface_check(self):
  82. """A bind IP that no longer matches any interface fails the check."""
  83. with (
  84. patch(_DIAG, AsyncMock(return_value=True)),
  85. patch(_FIND_IFACE, return_value=None),
  86. ):
  87. result = await run_vp_diagnostic(_vp(), _FakeInstance())
  88. c = _checks(result)
  89. assert c["bind_interface"] == "fail"
  90. assert result.overall == "problems"
  91. @pytest.mark.asyncio
  92. async def test_missing_access_code_fails_non_proxy(self):
  93. with (
  94. patch(_DIAG, AsyncMock(return_value=True)),
  95. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  96. ):
  97. result = await run_vp_diagnostic(_vp(access_code=None), _FakeInstance())
  98. assert _checks(result)["access_code"] == "fail"
  99. @pytest.mark.asyncio
  100. async def test_proxy_mode_skips_access_code_and_bind_port(self):
  101. """Proxy mode has no access code and runs no bind/detect server."""
  102. instance = _FakeInstance(proxy_status={"ftp_port": 3001, "mqtt_port": 3003})
  103. with (
  104. patch(_DIAG, AsyncMock(return_value=True)),
  105. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  106. ):
  107. result = await run_vp_diagnostic(_vp(mode="proxy", target_printer_id=7), instance)
  108. c = _checks(result)
  109. assert c["access_code"] == "skip"
  110. assert c["port_bind"] == "skip"
  111. assert c["port_ftps"] == "pass"
  112. assert c["port_mqtt"] == "pass"
  113. @pytest.mark.asyncio
  114. async def test_proxy_without_target_fails(self):
  115. """Proxy mode with no target printer fails the target check."""
  116. with (
  117. patch(_DIAG, AsyncMock(return_value=True)),
  118. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  119. ):
  120. result = await run_vp_diagnostic(
  121. _vp(mode="proxy", target_printer_id=None, access_code=None), _FakeInstance()
  122. )
  123. c = _checks(result)
  124. assert c["target_printer"] == "fail"
  125. assert result.overall == "problems"
  126. class TestCaCertificateInfo:
  127. def test_get_ca_certificate_info_generates_and_returns_pem(self):
  128. """The CA is generated on demand; the returned PEM is the public cert."""
  129. with tempfile.TemporaryDirectory() as d:
  130. service = CertificateService(cert_dir=Path(d), shared_ca_dir=Path(d))
  131. info = service.get_ca_certificate_info()
  132. assert info["pem"].startswith("-----BEGIN CERTIFICATE-----")
  133. assert "-----END CERTIFICATE-----" in info["pem"]
  134. # SHA-256 fingerprint: 32 colon-separated uppercase hex bytes.
  135. parts = info["fingerprint_sha256"].split(":")
  136. assert len(parts) == 32
  137. assert all(len(p) == 2 and p == p.upper() for p in parts)
  138. assert info["not_valid_after"]
  139. def test_ca_certificate_info_is_stable_across_calls(self):
  140. """A second call reuses the persisted CA — same fingerprint, no key leak."""
  141. with tempfile.TemporaryDirectory() as d:
  142. service = CertificateService(cert_dir=Path(d), shared_ca_dir=Path(d))
  143. first = service.get_ca_certificate_info()
  144. second = service.get_ca_certificate_info()
  145. assert first["fingerprint_sha256"] == second["fingerprint_sha256"]
  146. assert "PRIVATE KEY" not in first["pem"]
  147. class TestPrivilegedPortsCheck:
  148. """#2549: the VP binds 990 (FTPS) and 322 (RTSP), both below 1024.
  149. Without CAP_NET_BIND_SERVICE those sockets never open and the slicer never
  150. sees the printer. The port probes alone report the same "nothing is
  151. listening" as an ordinary port conflict, which is what sent the reporter to
  152. Discord for days over one missing line in a systemd unit. This check names
  153. the cause — but only when a port actually failed, since the capability can
  154. legitimately be absent on a host that fronts 990 some other way.
  155. """
  156. _CAP = "backend.app.services.virtual_printer.diagnostic.can_bind_privileged_ports"
  157. @pytest.mark.asyncio
  158. async def test_missing_capability_explains_a_dead_port(self):
  159. with (
  160. patch(_DIAG, AsyncMock(return_value=False)),
  161. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  162. patch(self._CAP, return_value=False),
  163. ):
  164. result = await run_vp_diagnostic(_vp(), _FakeInstance())
  165. assert _checks(result)["privileged_ports"] == "fail"
  166. @pytest.mark.asyncio
  167. async def test_missing_capability_is_not_flagged_when_the_port_answers(self):
  168. """An iptables REDIRECT is a documented alternative to the capability.
  169. Flagging a setup that demonstrably works would be noise."""
  170. with (
  171. patch(_DIAG, AsyncMock(return_value=True)),
  172. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  173. patch(self._CAP, return_value=False),
  174. ):
  175. result = await run_vp_diagnostic(_vp(), _FakeInstance())
  176. assert _checks(result)["privileged_ports"] == "pass"
  177. assert result.overall == "ok"
  178. @pytest.mark.asyncio
  179. async def test_dead_port_with_the_capability_held_is_not_blamed_on_it(self):
  180. """The port is down for some other reason — a conflict, a crashed
  181. service. Saying "missing capability" here would misdirect the user."""
  182. with (
  183. patch(_DIAG, AsyncMock(return_value=False)),
  184. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  185. patch(self._CAP, return_value=True),
  186. ):
  187. result = await run_vp_diagnostic(_vp(), _FakeInstance())
  188. c = _checks(result)
  189. assert c["privileged_ports"] == "pass"
  190. assert c["port_ftps"] == "fail"
  191. @pytest.mark.asyncio
  192. async def test_undeterminable_capability_skips(self):
  193. """macOS / Windows have no procfs and no such capability model."""
  194. with (
  195. patch(_DIAG, AsyncMock(return_value=False)),
  196. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  197. patch(self._CAP, return_value=None),
  198. ):
  199. result = await run_vp_diagnostic(_vp(), _FakeInstance())
  200. assert _checks(result)["privileged_ports"] == "skip"
  201. @pytest.mark.asyncio
  202. async def test_not_running_skips(self):
  203. """Nothing was probed, so there is no failure to explain."""
  204. result = await run_vp_diagnostic(_vp(), _FakeInstance(running=False))
  205. assert _checks(result)["privileged_ports"] == "skip"
  206. class TestCanBindPrivilegedPorts:
  207. def test_root_can(self):
  208. with patch("os.geteuid", return_value=0):
  209. assert can_bind_privileged_ports() is True
  210. def test_effective_set_with_the_bit_set(self):
  211. # CAP_NET_BIND_SERVICE is capability 10, so bit 10 => 0x400.
  212. with (
  213. patch("os.geteuid", return_value=1000),
  214. patch("builtins.open", mock_open(read_data="Name:\tpython3\nCapEff:\t0000000000000400\n")),
  215. ):
  216. assert can_bind_privileged_ports() is True
  217. def test_effective_set_without_the_bit_set(self):
  218. with (
  219. patch("os.geteuid", return_value=1000),
  220. patch("builtins.open", mock_open(read_data="Name:\tpython3\nCapEff:\t0000000000000000\n")),
  221. ):
  222. assert can_bind_privileged_ports() is False
  223. def test_neighbouring_bits_do_not_count(self):
  224. """0x200 is capability 9 (CAP_NET_BROADCAST) and 0x800 is 11
  225. (CAP_NET_ADMIN) — neither grants a privileged bind."""
  226. with (
  227. patch("os.geteuid", return_value=1000),
  228. patch("builtins.open", mock_open(read_data="CapEff:\t0000000000000a00\n")),
  229. ):
  230. assert can_bind_privileged_ports() is False
  231. def test_no_procfs_is_undeterminable_not_false(self):
  232. """Returning False here would put a Linux-only fix instruction in front
  233. of a macOS user whose port failed for an unrelated reason."""
  234. with (
  235. patch("os.geteuid", return_value=1000),
  236. patch("builtins.open", side_effect=FileNotFoundError),
  237. ):
  238. assert can_bind_privileged_ports() is None