test_vp_diagnostic.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  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, patch
  6. import pytest
  7. from backend.app.services.virtual_printer.certificate import CertificateService
  8. from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
  9. _DIAG = "backend.app.services.virtual_printer.diagnostic._check_port"
  10. _FIND_IFACE = "backend.app.services.network_utils.find_interface_for_ip"
  11. def _vp(**overrides):
  12. """A virtual-printer DB row stand-in with sensible healthy defaults."""
  13. base = {
  14. "id": 1,
  15. "name": "Test VP",
  16. "mode": "immediate",
  17. "enabled": True,
  18. "bind_ip": "192.168.1.50",
  19. "access_code": "12345678",
  20. "target_printer_id": None,
  21. }
  22. base.update(overrides)
  23. return SimpleNamespace(**base)
  24. class _FakeInstance:
  25. """Minimal VirtualPrinterInstance stand-in for the diagnostic."""
  26. def __init__(self, running=True, cert_exists=True, proxy_status=None):
  27. self.is_running = running
  28. self._cert_exists = cert_exists
  29. self._proxy_status = proxy_status
  30. @property
  31. def cert_path(self):
  32. return SimpleNamespace(exists=lambda: self._cert_exists)
  33. def get_status(self):
  34. return {"proxy": self._proxy_status} if self._proxy_status is not None else {}
  35. def _checks(result):
  36. return {c.id: c.status for c in result.checks}
  37. class TestRunVpDiagnostic:
  38. @pytest.mark.asyncio
  39. async def test_disabled_vp_reports_problems(self):
  40. """A disabled VP fails the 'enabled' check; running/port checks skip."""
  41. result = await run_vp_diagnostic(_vp(enabled=False, bind_ip=None, access_code=None), None)
  42. c = _checks(result)
  43. assert result.overall == "problems"
  44. assert c["enabled"] == "fail"
  45. assert c["running"] == "skip"
  46. assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "skip"
  47. assert c["certificate"] == "skip"
  48. @pytest.mark.asyncio
  49. async def test_running_server_vp_all_pass(self):
  50. """Enabled + running + every port listening + cert present → overall ok."""
  51. with (
  52. patch(_DIAG, AsyncMock(return_value=True)),
  53. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  54. ):
  55. result = await run_vp_diagnostic(_vp(), _FakeInstance())
  56. c = _checks(result)
  57. assert result.overall == "ok"
  58. assert c["enabled"] == "pass"
  59. assert c["running"] == "pass"
  60. assert c["bind_interface"] == "pass"
  61. assert c["access_code"] == "pass"
  62. assert c["target_printer"] == "skip" # not proxy mode
  63. assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "pass"
  64. assert c["certificate"] == "pass"
  65. @pytest.mark.asyncio
  66. async def test_port_not_listening_is_a_problem(self):
  67. """A service object can exist while its socket never bound — the probe
  68. is what catches it, so a dead port must surface as a failure."""
  69. with (
  70. patch(_DIAG, AsyncMock(return_value=False)),
  71. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  72. ):
  73. result = await run_vp_diagnostic(_vp(), _FakeInstance())
  74. c = _checks(result)
  75. assert result.overall == "problems"
  76. assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "fail"
  77. @pytest.mark.asyncio
  78. async def test_stale_bind_ip_fails_interface_check(self):
  79. """A bind IP that no longer matches any interface fails the check."""
  80. with (
  81. patch(_DIAG, AsyncMock(return_value=True)),
  82. patch(_FIND_IFACE, return_value=None),
  83. ):
  84. result = await run_vp_diagnostic(_vp(), _FakeInstance())
  85. c = _checks(result)
  86. assert c["bind_interface"] == "fail"
  87. assert result.overall == "problems"
  88. @pytest.mark.asyncio
  89. async def test_missing_access_code_fails_non_proxy(self):
  90. with (
  91. patch(_DIAG, AsyncMock(return_value=True)),
  92. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  93. ):
  94. result = await run_vp_diagnostic(_vp(access_code=None), _FakeInstance())
  95. assert _checks(result)["access_code"] == "fail"
  96. @pytest.mark.asyncio
  97. async def test_proxy_mode_skips_access_code_and_bind_port(self):
  98. """Proxy mode has no access code and runs no bind/detect server."""
  99. instance = _FakeInstance(proxy_status={"ftp_port": 3001, "mqtt_port": 3003})
  100. with (
  101. patch(_DIAG, AsyncMock(return_value=True)),
  102. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  103. ):
  104. result = await run_vp_diagnostic(_vp(mode="proxy", target_printer_id=7), instance)
  105. c = _checks(result)
  106. assert c["access_code"] == "skip"
  107. assert c["port_bind"] == "skip"
  108. assert c["port_ftps"] == "pass"
  109. assert c["port_mqtt"] == "pass"
  110. @pytest.mark.asyncio
  111. async def test_proxy_without_target_fails(self):
  112. """Proxy mode with no target printer fails the target check."""
  113. with (
  114. patch(_DIAG, AsyncMock(return_value=True)),
  115. patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
  116. ):
  117. result = await run_vp_diagnostic(
  118. _vp(mode="proxy", target_printer_id=None, access_code=None), _FakeInstance()
  119. )
  120. c = _checks(result)
  121. assert c["target_printer"] == "fail"
  122. assert result.overall == "problems"
  123. class TestCaCertificateInfo:
  124. def test_get_ca_certificate_info_generates_and_returns_pem(self):
  125. """The CA is generated on demand; the returned PEM is the public cert."""
  126. with tempfile.TemporaryDirectory() as d:
  127. service = CertificateService(cert_dir=Path(d), shared_ca_dir=Path(d))
  128. info = service.get_ca_certificate_info()
  129. assert info["pem"].startswith("-----BEGIN CERTIFICATE-----")
  130. assert "-----END CERTIFICATE-----" in info["pem"]
  131. # SHA-256 fingerprint: 32 colon-separated uppercase hex bytes.
  132. parts = info["fingerprint_sha256"].split(":")
  133. assert len(parts) == 32
  134. assert all(len(p) == 2 and p == p.upper() for p in parts)
  135. assert info["not_valid_after"]
  136. def test_ca_certificate_info_is_stable_across_calls(self):
  137. """A second call reuses the persisted CA — same fingerprint, no key leak."""
  138. with tempfile.TemporaryDirectory() as d:
  139. service = CertificateService(cert_dir=Path(d), shared_ca_dir=Path(d))
  140. first = service.get_ca_certificate_info()
  141. second = service.get_ca_certificate_info()
  142. assert first["fingerprint_sha256"] == second["fingerprint_sha256"]
  143. assert "PRIVATE KEY" not in first["pem"]