test_tailscale.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759
  1. """Unit tests for TailscaleService and Tailscale-aware VirtualPrinterInstance."""
  2. import asyncio
  3. import json
  4. from datetime import datetime, timedelta, timezone
  5. from pathlib import Path
  6. from unittest.mock import AsyncMock, MagicMock, patch
  7. import pytest
  8. from cryptography import x509
  9. from cryptography.hazmat.primitives import hashes, serialization
  10. from cryptography.hazmat.primitives.asymmetric import rsa
  11. from cryptography.x509.oid import NameOID
  12. def _make_cert(tmp_path: Path, days_valid: int, fqdn: str | None = None) -> Path:
  13. """Write a self-signed cert valid for days_valid days and return its path.
  14. If fqdn is provided the cert includes a SubjectAlternativeName DNS entry.
  15. """
  16. key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  17. now = datetime.now(timezone.utc)
  18. builder = (
  19. x509.CertificateBuilder()
  20. .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test")]))
  21. .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test")]))
  22. .public_key(key.public_key())
  23. .serial_number(x509.random_serial_number())
  24. .not_valid_before(now)
  25. .not_valid_after(now + timedelta(days=days_valid))
  26. )
  27. if fqdn:
  28. builder = builder.add_extension(
  29. x509.SubjectAlternativeName([x509.DNSName(fqdn)]),
  30. critical=False,
  31. )
  32. cert = builder.sign(key, hashes.SHA256())
  33. path = tmp_path / "cert.crt"
  34. path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
  35. return path
  36. # =============================================================================
  37. # TailscaleService tests
  38. # =============================================================================
  39. class TestTailscaleService:
  40. """Tests for TailscaleService CLI wrapper."""
  41. # -- get_status --
  42. @pytest.mark.asyncio
  43. async def test_get_status_binary_not_found(self):
  44. """Returns available=False when the tailscale binary is absent from PATH."""
  45. from backend.app.services.virtual_printer.tailscale import TailscaleService
  46. svc = TailscaleService()
  47. with patch("shutil.which", return_value=None):
  48. status = await svc.get_status()
  49. assert status.available is False
  50. assert status.error is not None
  51. assert "not found" in status.error
  52. @pytest.mark.asyncio
  53. async def test_get_status_command_fails(self):
  54. """Returns available=False when the tailscale status command exits non-zero."""
  55. from backend.app.services.virtual_printer.tailscale import TailscaleService
  56. svc = TailscaleService()
  57. with (
  58. patch("shutil.which", return_value="/usr/bin/tailscale"),
  59. patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(1, b"", b"permission denied")),
  60. ):
  61. status = await svc.get_status()
  62. assert status.available is False
  63. assert "permission denied" in (status.error or "")
  64. @pytest.mark.asyncio
  65. async def test_get_status_success(self):
  66. """Parses FQDN, hostname, tailnet_name, and IP list from JSON output."""
  67. from backend.app.services.virtual_printer.tailscale import TailscaleService
  68. payload = {
  69. "Self": {
  70. "DNSName": "myhost.example.ts.net.",
  71. "TailscaleIPs": ["100.1.2.3", "fd7a::1"],
  72. }
  73. }
  74. svc = TailscaleService()
  75. with (
  76. patch("shutil.which", return_value="/usr/bin/tailscale"),
  77. patch.object(
  78. svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, json.dumps(payload).encode(), b"")
  79. ),
  80. ):
  81. status = await svc.get_status()
  82. assert status.available is True
  83. assert status.fqdn == "myhost.example.ts.net"
  84. assert status.hostname == "myhost"
  85. assert status.tailnet_name == "example.ts.net"
  86. assert "100.1.2.3" in status.tailscale_ips
  87. # -- provision_cert --
  88. @pytest.mark.asyncio
  89. async def test_provision_cert_success(self, tmp_path):
  90. """Returns True and forwards the correct arguments to _run_tailscale."""
  91. from backend.app.services.virtual_printer.tailscale import TailscaleService
  92. cert_path = tmp_path / "ts.crt"
  93. key_path = tmp_path / "ts.key"
  94. cert_path.write_text("fake-cert")
  95. key_path.write_text("fake-key")
  96. svc = TailscaleService()
  97. with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"", b"")) as mock_run:
  98. result = await svc.provision_cert("myhost.ts.net", cert_path, key_path)
  99. assert result is True
  100. called_args = mock_run.call_args[0] # positional args to _run_tailscale
  101. assert "cert" in called_args
  102. assert "--cert-file" in called_args
  103. assert str(cert_path) in called_args
  104. assert "myhost.ts.net" in called_args
  105. @pytest.mark.asyncio
  106. async def test_provision_cert_failure(self, tmp_path):
  107. """Returns False without raising when the tailscale cert command fails."""
  108. from backend.app.services.virtual_printer.tailscale import TailscaleService
  109. svc = TailscaleService()
  110. with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(1, b"", b"not logged in")):
  111. result = await svc.provision_cert("myhost.ts.net", tmp_path / "ts.crt", tmp_path / "ts.key")
  112. assert result is False
  113. # -- cert_needs_renewal --
  114. def test_cert_needs_renewal_absent(self, tmp_path):
  115. """Returns True when the cert file does not exist."""
  116. from backend.app.services.virtual_printer.tailscale import TailscaleService
  117. svc = TailscaleService()
  118. assert svc.cert_needs_renewal(tmp_path / "nonexistent.crt") is True
  119. def test_cert_needs_renewal_fresh(self, tmp_path):
  120. """Returns False when the cert has more than the threshold days remaining."""
  121. from backend.app.services.virtual_printer.tailscale import TailscaleService
  122. cert_path = _make_cert(tmp_path, days_valid=60)
  123. svc = TailscaleService()
  124. assert svc.cert_needs_renewal(cert_path) is False
  125. def test_cert_needs_renewal_expiring(self, tmp_path):
  126. """Returns True when the cert is within the renewal threshold."""
  127. from backend.app.services.virtual_printer.tailscale import (
  128. TS_CERT_EXPIRY_THRESHOLD_DAYS,
  129. TailscaleService,
  130. )
  131. cert_path = _make_cert(tmp_path, days_valid=TS_CERT_EXPIRY_THRESHOLD_DAYS - 1)
  132. svc = TailscaleService()
  133. assert svc.cert_needs_renewal(cert_path) is True
  134. # -- ensure_cert --
  135. @pytest.mark.asyncio
  136. async def test_ensure_cert_skips_provision_when_fresh(self, tmp_path):
  137. """Does not call provision_cert when the existing cert is still fresh."""
  138. from backend.app.services.virtual_printer.tailscale import TailscaleService
  139. svc = TailscaleService()
  140. with (
  141. patch.object(svc, "cert_needs_renewal", return_value=False),
  142. patch.object(svc, "provision_cert", new_callable=AsyncMock) as mock_prov,
  143. ):
  144. result = await svc.ensure_cert("h.ts.net", tmp_path / "ts.crt", tmp_path / "ts.key")
  145. assert result is True
  146. mock_prov.assert_not_called()
  147. @pytest.mark.asyncio
  148. async def test_ensure_cert_provisions_when_absent(self, tmp_path):
  149. """Calls provision_cert when no valid cert exists."""
  150. from backend.app.services.virtual_printer.tailscale import TailscaleService
  151. svc = TailscaleService()
  152. with (
  153. patch.object(svc, "cert_needs_renewal", return_value=True),
  154. patch.object(svc, "provision_cert", new_callable=AsyncMock, return_value=True) as mock_prov,
  155. ):
  156. result = await svc.ensure_cert("h.ts.net", tmp_path / "ts.crt", tmp_path / "ts.key")
  157. assert result is True
  158. mock_prov.assert_called_once()
  159. # =============================================================================
  160. # VirtualPrinterInstance Tailscale integration tests
  161. # =============================================================================
  162. class TestVirtualPrinterInstanceTailscale:
  163. """Tests for Tailscale cert/advertise resolution in VirtualPrinterInstance."""
  164. @pytest.fixture
  165. def instance(self, tmp_path):
  166. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  167. return VirtualPrinterInstance(
  168. vp_id=1,
  169. name="TestPrinter",
  170. mode="immediate",
  171. model="C11",
  172. access_code="12345678",
  173. serial_suffix="391800001",
  174. base_dir=tmp_path,
  175. )
  176. @pytest.mark.asyncio
  177. async def test_resolve_uses_tailscale_when_available(self, instance):
  178. """Returns TS cert paths and FQDN advertise address when Tailscale is up."""
  179. from backend.app.services.virtual_printer.tailscale import TailscaleStatus
  180. ts_cert = instance.cert_dir / "virtual_printer_ts.crt"
  181. ts_key = instance.cert_dir / "virtual_printer_ts.key"
  182. mock_ts = MagicMock()
  183. mock_ts.get_status = AsyncMock(
  184. return_value=TailscaleStatus(
  185. available=True,
  186. hostname="myhost",
  187. tailnet_name="example.ts.net",
  188. fqdn="myhost.example.ts.net",
  189. tailscale_ips=["100.1.2.3"],
  190. )
  191. )
  192. with (
  193. patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts),
  194. patch.object(
  195. instance._cert_service,
  196. "use_tailscale_cert",
  197. new_callable=AsyncMock,
  198. return_value=(ts_cert, ts_key),
  199. ),
  200. ):
  201. cert_path, key_path, advertise = await instance._resolve_cert_and_advertise()
  202. assert cert_path == ts_cert
  203. assert key_path == ts_key
  204. assert advertise == "myhost.example.ts.net"
  205. assert instance.tailscale_fqdn == "myhost.example.ts.net"
  206. @pytest.mark.asyncio
  207. async def test_resolve_falls_back_to_selfsigned(self, instance, tmp_path):
  208. """Falls back to self-signed cert and IP string when Tailscale is absent."""
  209. from backend.app.services.virtual_printer.tailscale import TailscaleStatus
  210. self_cert = tmp_path / "cert.crt"
  211. self_key = tmp_path / "cert.key"
  212. mock_ts = MagicMock()
  213. mock_ts.get_status = AsyncMock(
  214. return_value=TailscaleStatus(
  215. available=False,
  216. hostname="",
  217. tailnet_name="",
  218. fqdn="",
  219. error="tailscale binary not found",
  220. )
  221. )
  222. with (
  223. patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts),
  224. patch.object(instance, "generate_certificates", return_value=(self_cert, self_key)),
  225. ):
  226. cert_path, key_path, advertise = await instance._resolve_cert_and_advertise()
  227. assert cert_path == self_cert
  228. assert key_path == self_key
  229. assert instance.tailscale_fqdn is None
  230. assert isinstance(advertise, str)
  231. def test_tailscale_fqdn_in_status_when_set(self, instance):
  232. """get_status() includes tailscale_fqdn when it is set."""
  233. instance.tailscale_fqdn = "myhost.example.ts.net"
  234. status = instance.get_status()
  235. assert status.get("tailscale_fqdn") == "myhost.example.ts.net"
  236. def test_tailscale_fqdn_absent_from_status_when_none(self, instance):
  237. """get_status() omits the tailscale_fqdn key when tailscale_fqdn is None."""
  238. instance.tailscale_fqdn = None
  239. status = instance.get_status()
  240. assert "tailscale_fqdn" not in status
  241. @pytest.mark.asyncio
  242. async def test_tailscale_disabled_skips_tailscale_entirely(self, tmp_path):
  243. """When tailscale_disabled=True, Tailscale is never queried and self-signed cert is used."""
  244. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  245. self_cert = tmp_path / "cert.crt"
  246. self_key = tmp_path / "cert.key"
  247. instance = VirtualPrinterInstance(
  248. vp_id=2,
  249. name="NoTailscale",
  250. mode="immediate",
  251. model="C11",
  252. access_code="12345678",
  253. serial_suffix="391800001",
  254. tailscale_disabled=True,
  255. base_dir=tmp_path,
  256. )
  257. mock_ts = MagicMock()
  258. mock_ts.get_status = AsyncMock()
  259. with (
  260. patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts),
  261. patch.object(instance, "generate_certificates", return_value=(self_cert, self_key)),
  262. ):
  263. cert_path, key_path, advertise = await instance._resolve_cert_and_advertise()
  264. # Tailscale must never have been queried
  265. mock_ts.get_status.assert_not_called()
  266. assert cert_path == self_cert
  267. assert key_path == self_key
  268. assert instance.tailscale_fqdn is None
  269. @pytest.mark.asyncio
  270. async def test_tailscale_enabled_by_default_queries_tailscale(self, instance):
  271. """When tailscale_disabled=False (default), Tailscale is queried as usual."""
  272. from backend.app.services.virtual_printer.tailscale import TailscaleStatus
  273. mock_ts = MagicMock()
  274. mock_ts.get_status = AsyncMock(
  275. return_value=TailscaleStatus(
  276. available=False,
  277. hostname="",
  278. tailnet_name="",
  279. fqdn="",
  280. error="not connected",
  281. )
  282. )
  283. self_cert = instance.cert_dir / "cert.crt"
  284. self_key = instance.cert_dir / "cert.key"
  285. with (
  286. patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts),
  287. patch.object(instance, "generate_certificates", return_value=(self_cert, self_key)),
  288. ):
  289. await instance._resolve_cert_and_advertise()
  290. mock_ts.get_status.assert_called_once()
  291. # =============================================================================
  292. # cert_needs_renewal — FQDN SAN validation, exception narrowing, FQDN regex
  293. # =============================================================================
  294. class TestCertNeedsRenewalExtended:
  295. """Extended tests for cert_needs_renewal() covering new FQDN and exception logic."""
  296. def test_fqdn_match_fresh_cert_not_renewed(self, tmp_path):
  297. """Fresh cert whose SAN matches the requested FQDN is not renewed."""
  298. from backend.app.services.virtual_printer.tailscale import TailscaleService
  299. fqdn = "myhost.example.ts.net"
  300. cert_path = _make_cert(tmp_path, days_valid=60, fqdn=fqdn)
  301. svc = TailscaleService()
  302. assert svc.cert_needs_renewal(cert_path, fqdn=fqdn) is False
  303. def test_fqdn_mismatch_triggers_renewal(self, tmp_path):
  304. """Fresh cert whose SAN does NOT match the requested FQDN triggers renewal."""
  305. from backend.app.services.virtual_printer.tailscale import TailscaleService
  306. cert_path = _make_cert(tmp_path, days_valid=60, fqdn="oldhost.example.ts.net")
  307. svc = TailscaleService()
  308. assert svc.cert_needs_renewal(cert_path, fqdn="newhost.example.ts.net") is True
  309. def test_cert_without_san_triggers_renewal_when_fqdn_given(self, tmp_path):
  310. """Cert with no SAN extension triggers renewal when an FQDN is requested."""
  311. from backend.app.services.virtual_printer.tailscale import TailscaleService
  312. cert_path = _make_cert(tmp_path, days_valid=60, fqdn=None)
  313. svc = TailscaleService()
  314. assert svc.cert_needs_renewal(cert_path, fqdn="myhost.example.ts.net") is True
  315. def test_fqdn_not_checked_when_none(self, tmp_path):
  316. """Fresh cert with no SAN is valid when no FQDN is requested (backward-compat)."""
  317. from backend.app.services.virtual_printer.tailscale import TailscaleService
  318. cert_path = _make_cert(tmp_path, days_valid=60, fqdn=None)
  319. svc = TailscaleService()
  320. assert svc.cert_needs_renewal(cert_path, fqdn=None) is False
  321. def test_narrow_exception_oserror_triggers_renewal(self, tmp_path):
  322. """OSError while reading the cert file triggers renewal."""
  323. from unittest.mock import patch
  324. from backend.app.services.virtual_printer.tailscale import TailscaleService
  325. cert_path = _make_cert(tmp_path, days_valid=60)
  326. svc = TailscaleService()
  327. with patch("pathlib.Path.read_bytes", side_effect=OSError("permission denied")):
  328. assert svc.cert_needs_renewal(cert_path) is True
  329. def test_narrow_exception_valueerror_triggers_renewal(self, tmp_path):
  330. """ValueError (bad PEM data) while loading the cert triggers renewal."""
  331. from backend.app.services.virtual_printer.tailscale import TailscaleService
  332. cert_path = tmp_path / "bad.crt"
  333. cert_path.write_bytes(b"not a valid pem")
  334. svc = TailscaleService()
  335. assert svc.cert_needs_renewal(cert_path) is True
  336. def test_programming_error_propagates(self, tmp_path):
  337. """Unexpected exceptions (not OSError/ValueError) are NOT silently swallowed."""
  338. from unittest.mock import patch
  339. from backend.app.services.virtual_printer.tailscale import TailscaleService
  340. cert_path = _make_cert(tmp_path, days_valid=60)
  341. svc = TailscaleService()
  342. with (
  343. patch("pathlib.Path.read_bytes", side_effect=RuntimeError("unexpected")),
  344. pytest.raises(RuntimeError, match="unexpected"),
  345. ):
  346. svc.cert_needs_renewal(cert_path)
  347. class TestProvisionCertFQDNValidation:
  348. """Tests for FQDN input validation in provision_cert()."""
  349. @pytest.mark.asyncio
  350. async def test_invalid_fqdn_rejected_without_subprocess(self, tmp_path):
  351. """provision_cert() returns False immediately for an invalid FQDN."""
  352. from backend.app.services.virtual_printer.tailscale import TailscaleService
  353. svc = TailscaleService()
  354. with patch.object(svc, "_run_tailscale", new_callable=AsyncMock) as mock_run:
  355. result = await svc.provision_cert("../evil", tmp_path / "c.crt", tmp_path / "k.key")
  356. assert result is False
  357. mock_run.assert_not_called()
  358. @pytest.mark.asyncio
  359. async def test_single_label_fqdn_rejected(self, tmp_path):
  360. """A hostname without dots (no tailnet) is rejected."""
  361. from backend.app.services.virtual_printer.tailscale import TailscaleService
  362. svc = TailscaleService()
  363. with patch.object(svc, "_run_tailscale", new_callable=AsyncMock) as mock_run:
  364. result = await svc.provision_cert("justhostname", tmp_path / "c.crt", tmp_path / "k.key")
  365. assert result is False
  366. mock_run.assert_not_called()
  367. @pytest.mark.asyncio
  368. async def test_valid_fqdn_passes_to_subprocess(self, tmp_path):
  369. """A valid FQDN is forwarded to _run_tailscale."""
  370. from backend.app.services.virtual_printer.tailscale import TailscaleService
  371. key_path = tmp_path / "k.key"
  372. cert_path = tmp_path / "c.crt"
  373. cert_path.write_text("fake-cert")
  374. key_path.write_text("fake")
  375. svc = TailscaleService()
  376. with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"", b"")) as mock_run:
  377. result = await svc.provision_cert("myhost.example.ts.net", cert_path, key_path)
  378. assert result is True
  379. assert "myhost.example.ts.net" in mock_run.call_args[0]
  380. # =============================================================================
  381. # Additional coverage: OSError path, JSON error, CertificateService wrapper
  382. # =============================================================================
  383. class TestProvisionCertOSError:
  384. """provision_cert returns False when _run_tailscale raises OSError."""
  385. @pytest.mark.asyncio
  386. async def test_oserror_returns_false(self, tmp_path):
  387. from backend.app.services.virtual_printer.tailscale import TailscaleService
  388. svc = TailscaleService()
  389. with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, side_effect=OSError("no binary")):
  390. result = await svc.provision_cert("myhost.ts.net", tmp_path / "c.crt", tmp_path / "k.key")
  391. assert result is False
  392. class TestProvisionCertHTTPSDisabled:
  393. """provision_cert logs an actionable message when the tailnet has HTTPS certs disabled."""
  394. @pytest.mark.asyncio
  395. async def test_https_disabled_logs_admin_url(self, tmp_path, caplog):
  396. from backend.app.services.virtual_printer.tailscale import TailscaleService
  397. svc = TailscaleService()
  398. disabled_stderr = b"HTTPS cert generation is disabled for this tailnet"
  399. with (
  400. patch.object(
  401. svc,
  402. "_run_tailscale",
  403. new_callable=AsyncMock,
  404. return_value=(1, b"", disabled_stderr),
  405. ),
  406. caplog.at_level("WARNING"),
  407. ):
  408. result = await svc.provision_cert("myhost.ts.net", tmp_path / "c.crt", tmp_path / "k.key")
  409. assert result is False
  410. assert "login.tailscale.com/admin/dns" in caplog.text
  411. @pytest.mark.asyncio
  412. async def test_generic_error_logs_exit_code(self, tmp_path, caplog):
  413. from backend.app.services.virtual_printer.tailscale import TailscaleService
  414. svc = TailscaleService()
  415. with (
  416. patch.object(
  417. svc,
  418. "_run_tailscale",
  419. new_callable=AsyncMock,
  420. return_value=(1, b"", b"some other error"),
  421. ),
  422. caplog.at_level("WARNING"),
  423. ):
  424. result = await svc.provision_cert("myhost.ts.net", tmp_path / "c.crt", tmp_path / "k.key")
  425. assert result is False
  426. assert "exit 1" in caplog.text
  427. assert "login.tailscale.com" not in caplog.text
  428. class TestProvisionCertReadability:
  429. """provision_cert returns False when cert files are not readable after provisioning."""
  430. @pytest.mark.asyncio
  431. async def test_unreadable_key_returns_false(self, tmp_path, caplog):
  432. from backend.app.services.virtual_printer.tailscale import TailscaleService
  433. svc = TailscaleService()
  434. cert_path = tmp_path / "c.crt"
  435. key_path = tmp_path / "k.key"
  436. with (
  437. patch.object(
  438. svc,
  439. "_run_tailscale",
  440. new_callable=AsyncMock,
  441. return_value=(0, b"", b""),
  442. ),
  443. patch("os.access", return_value=False),
  444. caplog.at_level("ERROR"),
  445. ):
  446. result = await svc.provision_cert("myhost.ts.net", cert_path, key_path)
  447. assert result is False
  448. assert "not readable" in caplog.text
  449. assert "chown" in caplog.text
  450. class TestGetStatusJSONError:
  451. """get_status returns available=False when tailscale outputs non-JSON."""
  452. @pytest.mark.asyncio
  453. async def test_bad_json_returns_unavailable(self):
  454. from backend.app.services.virtual_printer.tailscale import TailscaleService
  455. svc = TailscaleService()
  456. with (
  457. patch("shutil.which", return_value="/usr/bin/tailscale"),
  458. patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"not json {{", b"")),
  459. ):
  460. status = await svc.get_status()
  461. assert status.available is False
  462. assert status.error is not None
  463. assert "JSON" in status.error
  464. class TestUseTailscaleCertWrapper:
  465. """CertificateService.use_tailscale_cert delegates to tailscale_svc.ensure_cert."""
  466. @pytest.mark.asyncio
  467. async def test_returns_paths_on_success(self, tmp_path):
  468. from backend.app.services.virtual_printer.certificate import CertificateService
  469. svc = CertificateService(cert_dir=tmp_path, serial="00M09A391800001")
  470. mock_ts = MagicMock()
  471. mock_ts.ensure_cert = AsyncMock(return_value=True)
  472. result = await svc.use_tailscale_cert("myhost.ts.net", mock_ts)
  473. assert result == (svc.ts_cert_path, svc.ts_key_path)
  474. mock_ts.ensure_cert.assert_called_once_with("myhost.ts.net", svc.ts_cert_path, svc.ts_key_path)
  475. @pytest.mark.asyncio
  476. async def test_returns_none_on_failure(self, tmp_path):
  477. from backend.app.services.virtual_printer.certificate import CertificateService
  478. svc = CertificateService(cert_dir=tmp_path, serial="00M09A391800001")
  479. mock_ts = MagicMock()
  480. mock_ts.ensure_cert = AsyncMock(return_value=False)
  481. result = await svc.use_tailscale_cert("myhost.ts.net", mock_ts)
  482. assert result is None
  483. # =============================================================================
  484. # _cert_renewal_loop tests
  485. # =============================================================================
  486. class TestCertRenewalLoop:
  487. """Tests for VirtualPrinterInstance._cert_renewal_loop."""
  488. @pytest.fixture
  489. def instance(self, tmp_path):
  490. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  491. return VirtualPrinterInstance(
  492. vp_id=99,
  493. name="RenewalTestPrinter",
  494. mode="immediate",
  495. model="C11",
  496. access_code="12345678",
  497. serial_suffix="391800001",
  498. base_dir=tmp_path,
  499. )
  500. @pytest.mark.asyncio
  501. async def test_loop_skips_when_fqdn_not_set(self, instance):
  502. """Loop does nothing when tailscale_fqdn is None — just sleeps."""
  503. instance.tailscale_fqdn = None
  504. sleep_call_count = [0]
  505. async def fast_sleep(n):
  506. sleep_call_count[0] += 1
  507. if sleep_call_count[0] >= 2:
  508. raise asyncio.CancelledError()
  509. with (
  510. patch("asyncio.sleep", side_effect=fast_sleep),
  511. patch.object(instance._cert_service, "use_tailscale_cert", new_callable=AsyncMock) as mock_use,
  512. ):
  513. task = asyncio.create_task(instance._cert_renewal_loop())
  514. try:
  515. await task
  516. except asyncio.CancelledError:
  517. pass
  518. mock_use.assert_not_called()
  519. @pytest.mark.asyncio
  520. async def test_loop_calls_renewal_when_cert_needs_it(self, instance):
  521. """Loop calls use_tailscale_cert when fqdn is set and cert needs renewal."""
  522. instance.tailscale_fqdn = "myhost.ts.net"
  523. async def fast_sleep(n):
  524. raise asyncio.CancelledError()
  525. with (
  526. patch("asyncio.sleep", side_effect=fast_sleep),
  527. patch("backend.app.services.virtual_printer.manager.tailscale_service") as mock_ts,
  528. patch.object(
  529. instance._cert_service, "use_tailscale_cert", new_callable=AsyncMock, return_value=None
  530. ) as mock_use,
  531. ):
  532. mock_ts.cert_needs_renewal.return_value = True
  533. task = asyncio.create_task(instance._cert_renewal_loop())
  534. try:
  535. await task
  536. except asyncio.CancelledError:
  537. pass
  538. mock_use.assert_called_once()
  539. @pytest.mark.asyncio
  540. async def test_loop_cancelled_error_exits_cleanly(self, instance):
  541. """CancelledError in the sleep breaks the loop without raising."""
  542. instance.tailscale_fqdn = None
  543. async def immediate_cancel(n):
  544. raise asyncio.CancelledError()
  545. with patch("asyncio.sleep", side_effect=immediate_cancel):
  546. task = asyncio.create_task(instance._cert_renewal_loop())
  547. await task # must complete without raising
  548. @pytest.mark.asyncio
  549. async def test_loop_backs_off_on_unexpected_error(self, instance):
  550. """Unexpected exceptions are logged and the loop backs off with a 3600 s sleep."""
  551. instance.tailscale_fqdn = "myhost.ts.net"
  552. sleep_args: list[float] = []
  553. async def tracking_sleep(n):
  554. sleep_args.append(n)
  555. if len(sleep_args) >= 2:
  556. raise asyncio.CancelledError()
  557. with (
  558. patch("asyncio.sleep", side_effect=tracking_sleep),
  559. patch("backend.app.services.virtual_printer.manager.tailscale_service") as mock_ts,
  560. ):
  561. mock_ts.cert_needs_renewal.side_effect = RuntimeError("unexpected db error")
  562. task = asyncio.create_task(instance._cert_renewal_loop())
  563. try:
  564. await task
  565. except asyncio.CancelledError:
  566. pass
  567. assert 3600 in sleep_args
  568. @pytest.mark.asyncio
  569. async def test_loop_schedules_restart_after_renewal(self, instance):
  570. """When a renewal succeeds, a restart task is scheduled and the loop exits."""
  571. instance.tailscale_fqdn = "myhost.ts.net"
  572. restart_scheduled = [False]
  573. _real_create_task = asyncio.create_task
  574. def tracking_create_task(coro, *, name=None):
  575. if name and "cert_restart" in name:
  576. restart_scheduled[0] = True
  577. coro.close()
  578. # Return a dummy completed task
  579. fut = asyncio.get_event_loop().create_future()
  580. fut.set_result(None)
  581. return fut
  582. return _real_create_task(coro, name=name)
  583. with (
  584. patch("asyncio.sleep", new_callable=AsyncMock),
  585. patch.object(asyncio, "create_task", side_effect=tracking_create_task),
  586. patch("backend.app.services.virtual_printer.manager.tailscale_service") as mock_ts,
  587. patch.object(
  588. instance._cert_service,
  589. "use_tailscale_cert",
  590. new_callable=AsyncMock,
  591. return_value=(instance._cert_service.ts_cert_path, instance._cert_service.ts_key_path),
  592. ),
  593. ):
  594. mock_ts.cert_needs_renewal.return_value = True
  595. # Run the loop directly; it exits via break after scheduling the restart
  596. task = _real_create_task(instance._cert_renewal_loop())
  597. await task
  598. assert restart_scheduled[0] is True