test_tailscale.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829
  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. # Tailscale is opt-in (default True); tests in this class exercise the enabled
  168. # path, so explicitly opt in.
  169. return VirtualPrinterInstance(
  170. vp_id=1,
  171. name="TestPrinter",
  172. mode="immediate",
  173. model="C11",
  174. access_code="12345678",
  175. serial_suffix="391800001",
  176. tailscale_disabled=False,
  177. base_dir=tmp_path,
  178. )
  179. @pytest.mark.asyncio
  180. async def test_resolve_uses_tailscale_when_available(self, instance):
  181. """Returns TS cert paths and FQDN advertise address when Tailscale is up."""
  182. from backend.app.services.virtual_printer.tailscale import TailscaleStatus
  183. ts_cert = instance.cert_dir / "virtual_printer_ts.crt"
  184. ts_key = instance.cert_dir / "virtual_printer_ts.key"
  185. mock_ts = MagicMock()
  186. mock_ts.get_status = AsyncMock(
  187. return_value=TailscaleStatus(
  188. available=True,
  189. hostname="myhost",
  190. tailnet_name="example.ts.net",
  191. fqdn="myhost.example.ts.net",
  192. tailscale_ips=["100.1.2.3"],
  193. )
  194. )
  195. with (
  196. patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts),
  197. patch.object(
  198. instance._cert_service,
  199. "use_tailscale_cert",
  200. new_callable=AsyncMock,
  201. return_value=(ts_cert, ts_key),
  202. ),
  203. ):
  204. cert_path, key_path, advertise = await instance._resolve_cert_and_advertise()
  205. assert cert_path == ts_cert
  206. assert key_path == ts_key
  207. assert advertise == "myhost.example.ts.net"
  208. assert instance.tailscale_fqdn == "myhost.example.ts.net"
  209. @pytest.mark.asyncio
  210. async def test_resolve_falls_back_to_selfsigned(self, instance, tmp_path):
  211. """Falls back to self-signed cert and IP string when Tailscale is absent."""
  212. from backend.app.services.virtual_printer.tailscale import TailscaleStatus
  213. self_cert = tmp_path / "cert.crt"
  214. self_key = tmp_path / "cert.key"
  215. mock_ts = MagicMock()
  216. mock_ts.get_status = AsyncMock(
  217. return_value=TailscaleStatus(
  218. available=False,
  219. hostname="",
  220. tailnet_name="",
  221. fqdn="",
  222. error="tailscale binary not found",
  223. )
  224. )
  225. with (
  226. patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts),
  227. patch.object(instance, "generate_certificates", return_value=(self_cert, self_key)),
  228. ):
  229. cert_path, key_path, advertise = await instance._resolve_cert_and_advertise()
  230. assert cert_path == self_cert
  231. assert key_path == self_key
  232. assert instance.tailscale_fqdn is None
  233. assert isinstance(advertise, str)
  234. def test_tailscale_fqdn_in_status_when_set(self, instance):
  235. """get_status() includes tailscale_fqdn when it is set."""
  236. instance.tailscale_fqdn = "myhost.example.ts.net"
  237. status = instance.get_status()
  238. assert status.get("tailscale_fqdn") == "myhost.example.ts.net"
  239. def test_tailscale_fqdn_absent_from_status_when_none(self, instance):
  240. """get_status() omits the tailscale_fqdn key when tailscale_fqdn is None."""
  241. instance.tailscale_fqdn = None
  242. status = instance.get_status()
  243. assert "tailscale_fqdn" not in status
  244. @pytest.mark.asyncio
  245. async def test_tailscale_disabled_skips_tailscale_entirely(self, tmp_path):
  246. """When tailscale_disabled=True, Tailscale is never queried and self-signed cert is used."""
  247. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  248. self_cert = tmp_path / "cert.crt"
  249. self_key = tmp_path / "cert.key"
  250. instance = VirtualPrinterInstance(
  251. vp_id=2,
  252. name="NoTailscale",
  253. mode="immediate",
  254. model="C11",
  255. access_code="12345678",
  256. serial_suffix="391800001",
  257. tailscale_disabled=True,
  258. base_dir=tmp_path,
  259. )
  260. mock_ts = MagicMock()
  261. mock_ts.get_status = AsyncMock()
  262. with (
  263. patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts),
  264. patch.object(instance, "generate_certificates", return_value=(self_cert, self_key)),
  265. ):
  266. cert_path, key_path, advertise = await instance._resolve_cert_and_advertise()
  267. # Tailscale must never have been queried
  268. mock_ts.get_status.assert_not_called()
  269. assert cert_path == self_cert
  270. assert key_path == self_key
  271. assert instance.tailscale_fqdn is None
  272. @pytest.mark.asyncio
  273. async def test_tailscale_enabled_explicitly_queries_tailscale(self, instance):
  274. """When tailscale_disabled=False (user opted in), Tailscale is queried as usual."""
  275. from backend.app.services.virtual_printer.tailscale import TailscaleStatus
  276. mock_ts = MagicMock()
  277. mock_ts.get_status = AsyncMock(
  278. return_value=TailscaleStatus(
  279. available=False,
  280. hostname="",
  281. tailnet_name="",
  282. fqdn="",
  283. error="not connected",
  284. )
  285. )
  286. self_cert = instance.cert_dir / "cert.crt"
  287. self_key = instance.cert_dir / "cert.key"
  288. with (
  289. patch("backend.app.services.virtual_printer.manager.tailscale_service", mock_ts),
  290. patch.object(instance, "generate_certificates", return_value=(self_cert, self_key)),
  291. ):
  292. await instance._resolve_cert_and_advertise()
  293. mock_ts.get_status.assert_called_once()
  294. # =============================================================================
  295. # cert_needs_renewal — FQDN SAN validation, exception narrowing, FQDN regex
  296. # =============================================================================
  297. class TestCertNeedsRenewalExtended:
  298. """Extended tests for cert_needs_renewal() covering new FQDN and exception logic."""
  299. def test_fqdn_match_fresh_cert_not_renewed(self, tmp_path):
  300. """Fresh cert whose SAN matches the requested FQDN is not renewed."""
  301. from backend.app.services.virtual_printer.tailscale import TailscaleService
  302. fqdn = "myhost.example.ts.net"
  303. cert_path = _make_cert(tmp_path, days_valid=60, fqdn=fqdn)
  304. svc = TailscaleService()
  305. assert svc.cert_needs_renewal(cert_path, fqdn=fqdn) is False
  306. def test_fqdn_mismatch_triggers_renewal(self, tmp_path):
  307. """Fresh cert whose SAN does NOT match the requested FQDN triggers renewal."""
  308. from backend.app.services.virtual_printer.tailscale import TailscaleService
  309. cert_path = _make_cert(tmp_path, days_valid=60, fqdn="oldhost.example.ts.net")
  310. svc = TailscaleService()
  311. assert svc.cert_needs_renewal(cert_path, fqdn="newhost.example.ts.net") is True
  312. def test_cert_without_san_triggers_renewal_when_fqdn_given(self, tmp_path):
  313. """Cert with no SAN extension triggers renewal when an FQDN is requested."""
  314. from backend.app.services.virtual_printer.tailscale import TailscaleService
  315. cert_path = _make_cert(tmp_path, days_valid=60, fqdn=None)
  316. svc = TailscaleService()
  317. assert svc.cert_needs_renewal(cert_path, fqdn="myhost.example.ts.net") is True
  318. def test_fqdn_not_checked_when_none(self, tmp_path):
  319. """Fresh cert with no SAN is valid when no FQDN is requested (backward-compat)."""
  320. from backend.app.services.virtual_printer.tailscale import TailscaleService
  321. cert_path = _make_cert(tmp_path, days_valid=60, fqdn=None)
  322. svc = TailscaleService()
  323. assert svc.cert_needs_renewal(cert_path, fqdn=None) is False
  324. def test_narrow_exception_oserror_triggers_renewal(self, tmp_path):
  325. """OSError while reading the cert file triggers renewal."""
  326. from unittest.mock import patch
  327. from backend.app.services.virtual_printer.tailscale import TailscaleService
  328. cert_path = _make_cert(tmp_path, days_valid=60)
  329. svc = TailscaleService()
  330. with patch("pathlib.Path.read_bytes", side_effect=OSError("permission denied")):
  331. assert svc.cert_needs_renewal(cert_path) is True
  332. def test_narrow_exception_valueerror_triggers_renewal(self, tmp_path):
  333. """ValueError (bad PEM data) while loading the cert triggers renewal."""
  334. from backend.app.services.virtual_printer.tailscale import TailscaleService
  335. cert_path = tmp_path / "bad.crt"
  336. cert_path.write_bytes(b"not a valid pem")
  337. svc = TailscaleService()
  338. assert svc.cert_needs_renewal(cert_path) is True
  339. def test_programming_error_propagates(self, tmp_path):
  340. """Unexpected exceptions (not OSError/ValueError) are NOT silently swallowed."""
  341. from unittest.mock import patch
  342. from backend.app.services.virtual_printer.tailscale import TailscaleService
  343. cert_path = _make_cert(tmp_path, days_valid=60)
  344. svc = TailscaleService()
  345. with (
  346. patch("pathlib.Path.read_bytes", side_effect=RuntimeError("unexpected")),
  347. pytest.raises(RuntimeError, match="unexpected"),
  348. ):
  349. svc.cert_needs_renewal(cert_path)
  350. class TestProvisionCertFQDNValidation:
  351. """Tests for FQDN input validation in provision_cert()."""
  352. @pytest.mark.asyncio
  353. async def test_invalid_fqdn_rejected_without_subprocess(self, tmp_path):
  354. """provision_cert() returns False immediately for an invalid FQDN."""
  355. from backend.app.services.virtual_printer.tailscale import TailscaleService
  356. svc = TailscaleService()
  357. with patch.object(svc, "_run_tailscale", new_callable=AsyncMock) as mock_run:
  358. result = await svc.provision_cert("../evil", tmp_path / "c.crt", tmp_path / "k.key")
  359. assert result is False
  360. mock_run.assert_not_called()
  361. @pytest.mark.asyncio
  362. async def test_single_label_fqdn_rejected(self, tmp_path):
  363. """A hostname without dots (no tailnet) is rejected."""
  364. from backend.app.services.virtual_printer.tailscale import TailscaleService
  365. svc = TailscaleService()
  366. with patch.object(svc, "_run_tailscale", new_callable=AsyncMock) as mock_run:
  367. result = await svc.provision_cert("justhostname", tmp_path / "c.crt", tmp_path / "k.key")
  368. assert result is False
  369. mock_run.assert_not_called()
  370. @pytest.mark.asyncio
  371. async def test_valid_fqdn_passes_to_subprocess(self, tmp_path):
  372. """A valid FQDN is forwarded to _run_tailscale."""
  373. from backend.app.services.virtual_printer.tailscale import TailscaleService
  374. key_path = tmp_path / "k.key"
  375. cert_path = tmp_path / "c.crt"
  376. cert_path.write_text("fake-cert")
  377. key_path.write_text("fake")
  378. svc = TailscaleService()
  379. with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"", b"")) as mock_run:
  380. result = await svc.provision_cert("myhost.example.ts.net", cert_path, key_path)
  381. assert result is True
  382. assert "myhost.example.ts.net" in mock_run.call_args[0]
  383. # =============================================================================
  384. # Additional coverage: OSError path, JSON error, CertificateService wrapper
  385. # =============================================================================
  386. class TestProvisionCertOSError:
  387. """provision_cert returns False when _run_tailscale raises OSError."""
  388. @pytest.mark.asyncio
  389. async def test_oserror_returns_false(self, tmp_path):
  390. from backend.app.services.virtual_printer.tailscale import TailscaleService
  391. svc = TailscaleService()
  392. with patch.object(svc, "_run_tailscale", new_callable=AsyncMock, side_effect=OSError("no binary")):
  393. result = await svc.provision_cert("myhost.ts.net", tmp_path / "c.crt", tmp_path / "k.key")
  394. assert result is False
  395. class TestProvisionCertHTTPSDisabled:
  396. """provision_cert logs an actionable message when the tailnet has HTTPS certs disabled."""
  397. @pytest.mark.asyncio
  398. async def test_https_disabled_logs_admin_url(self, tmp_path, caplog):
  399. from backend.app.services.virtual_printer.tailscale import TailscaleService
  400. svc = TailscaleService()
  401. disabled_stderr = b"HTTPS cert generation is disabled for this tailnet"
  402. with (
  403. patch.object(
  404. svc,
  405. "_run_tailscale",
  406. new_callable=AsyncMock,
  407. return_value=(1, b"", disabled_stderr),
  408. ),
  409. caplog.at_level("WARNING"),
  410. ):
  411. result = await svc.provision_cert("myhost.ts.net", tmp_path / "c.crt", tmp_path / "k.key")
  412. assert result is False
  413. assert "login.tailscale.com/admin/dns" in caplog.text
  414. @pytest.mark.asyncio
  415. async def test_generic_error_logs_exit_code(self, tmp_path, caplog):
  416. from backend.app.services.virtual_printer.tailscale import TailscaleService
  417. svc = TailscaleService()
  418. with (
  419. patch.object(
  420. svc,
  421. "_run_tailscale",
  422. new_callable=AsyncMock,
  423. return_value=(1, b"", b"some other error"),
  424. ),
  425. caplog.at_level("WARNING"),
  426. ):
  427. result = await svc.provision_cert("myhost.ts.net", tmp_path / "c.crt", tmp_path / "k.key")
  428. assert result is False
  429. assert "exit 1" in caplog.text
  430. assert "login.tailscale.com" not in caplog.text
  431. class TestProvisionCertReadability:
  432. """provision_cert returns False when cert files are not readable after provisioning."""
  433. @pytest.mark.asyncio
  434. async def test_unreadable_key_returns_false(self, tmp_path, caplog):
  435. from backend.app.services.virtual_printer.tailscale import TailscaleService
  436. svc = TailscaleService()
  437. cert_path = tmp_path / "c.crt"
  438. key_path = tmp_path / "k.key"
  439. with (
  440. patch.object(
  441. svc,
  442. "_run_tailscale",
  443. new_callable=AsyncMock,
  444. return_value=(0, b"", b""),
  445. ),
  446. patch("os.access", return_value=False),
  447. caplog.at_level("ERROR"),
  448. ):
  449. result = await svc.provision_cert("myhost.ts.net", cert_path, key_path)
  450. assert result is False
  451. assert "not readable" in caplog.text
  452. assert "chown" in caplog.text
  453. class TestGetStatusJSONError:
  454. """get_status returns available=False when tailscale outputs non-JSON."""
  455. @pytest.mark.asyncio
  456. async def test_bad_json_returns_unavailable(self):
  457. from backend.app.services.virtual_printer.tailscale import TailscaleService
  458. svc = TailscaleService()
  459. with (
  460. patch("shutil.which", return_value="/usr/bin/tailscale"),
  461. patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"not json {{", b"")),
  462. ):
  463. status = await svc.get_status()
  464. assert status.available is False
  465. assert status.error is not None
  466. assert "JSON" in status.error
  467. class TestUseTailscaleCertWrapper:
  468. """CertificateService.use_tailscale_cert delegates to tailscale_svc.ensure_cert."""
  469. @pytest.mark.asyncio
  470. async def test_returns_paths_on_success(self, tmp_path):
  471. from backend.app.services.virtual_printer.certificate import CertificateService
  472. svc = CertificateService(cert_dir=tmp_path, serial="00M09A391800001")
  473. mock_ts = MagicMock()
  474. mock_ts.ensure_cert = AsyncMock(return_value=True)
  475. result = await svc.use_tailscale_cert("myhost.ts.net", mock_ts)
  476. assert result == (svc.ts_cert_path, svc.ts_key_path)
  477. mock_ts.ensure_cert.assert_called_once_with("myhost.ts.net", svc.ts_cert_path, svc.ts_key_path)
  478. @pytest.mark.asyncio
  479. async def test_returns_none_on_failure(self, tmp_path):
  480. from backend.app.services.virtual_printer.certificate import CertificateService
  481. svc = CertificateService(cert_dir=tmp_path, serial="00M09A391800001")
  482. mock_ts = MagicMock()
  483. mock_ts.ensure_cert = AsyncMock(return_value=False)
  484. result = await svc.use_tailscale_cert("myhost.ts.net", mock_ts)
  485. assert result is None
  486. # =============================================================================
  487. # _cert_renewal_loop tests
  488. # =============================================================================
  489. class TestCertRenewalLoop:
  490. """Tests for VirtualPrinterInstance._cert_renewal_loop."""
  491. @pytest.fixture
  492. def instance(self, tmp_path):
  493. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  494. return VirtualPrinterInstance(
  495. vp_id=99,
  496. name="RenewalTestPrinter",
  497. mode="immediate",
  498. model="C11",
  499. access_code="12345678",
  500. serial_suffix="391800001",
  501. base_dir=tmp_path,
  502. )
  503. @pytest.mark.asyncio
  504. async def test_loop_skips_when_fqdn_not_set(self, instance):
  505. """Loop does nothing when tailscale_fqdn is None — just sleeps."""
  506. instance.tailscale_fqdn = None
  507. sleep_call_count = [0]
  508. async def fast_sleep(n):
  509. sleep_call_count[0] += 1
  510. if sleep_call_count[0] >= 2:
  511. raise asyncio.CancelledError()
  512. with (
  513. patch("asyncio.sleep", side_effect=fast_sleep),
  514. patch.object(instance._cert_service, "use_tailscale_cert", new_callable=AsyncMock) as mock_use,
  515. ):
  516. task = asyncio.create_task(instance._cert_renewal_loop())
  517. try:
  518. await task
  519. except asyncio.CancelledError:
  520. pass
  521. mock_use.assert_not_called()
  522. @pytest.mark.asyncio
  523. async def test_loop_calls_renewal_when_cert_needs_it(self, instance):
  524. """Loop calls use_tailscale_cert when fqdn is set and cert needs renewal."""
  525. instance.tailscale_fqdn = "myhost.ts.net"
  526. async def fast_sleep(n):
  527. raise asyncio.CancelledError()
  528. with (
  529. patch("asyncio.sleep", side_effect=fast_sleep),
  530. patch("backend.app.services.virtual_printer.manager.tailscale_service") as mock_ts,
  531. patch.object(
  532. instance._cert_service, "use_tailscale_cert", new_callable=AsyncMock, return_value=None
  533. ) as mock_use,
  534. ):
  535. mock_ts.cert_needs_renewal.return_value = True
  536. task = asyncio.create_task(instance._cert_renewal_loop())
  537. try:
  538. await task
  539. except asyncio.CancelledError:
  540. pass
  541. mock_use.assert_called_once()
  542. @pytest.mark.asyncio
  543. async def test_loop_cancelled_error_exits_cleanly(self, instance):
  544. """CancelledError in the sleep breaks the loop without raising."""
  545. instance.tailscale_fqdn = None
  546. async def immediate_cancel(n):
  547. raise asyncio.CancelledError()
  548. with patch("asyncio.sleep", side_effect=immediate_cancel):
  549. task = asyncio.create_task(instance._cert_renewal_loop())
  550. await task # must complete without raising
  551. @pytest.mark.asyncio
  552. async def test_loop_backs_off_on_unexpected_error(self, instance):
  553. """Unexpected exceptions are logged and the loop backs off with a 3600 s sleep."""
  554. instance.tailscale_fqdn = "myhost.ts.net"
  555. sleep_args: list[float] = []
  556. async def tracking_sleep(n):
  557. sleep_args.append(n)
  558. if len(sleep_args) >= 2:
  559. raise asyncio.CancelledError()
  560. with (
  561. patch("asyncio.sleep", side_effect=tracking_sleep),
  562. patch("backend.app.services.virtual_printer.manager.tailscale_service") as mock_ts,
  563. ):
  564. mock_ts.cert_needs_renewal.side_effect = RuntimeError("unexpected db error")
  565. task = asyncio.create_task(instance._cert_renewal_loop())
  566. try:
  567. await task
  568. except asyncio.CancelledError:
  569. pass
  570. assert 3600 in sleep_args
  571. @pytest.mark.asyncio
  572. async def test_loop_schedules_restart_after_renewal(self, instance):
  573. """When a renewal succeeds, a restart task is scheduled and the loop exits."""
  574. instance.tailscale_fqdn = "myhost.ts.net"
  575. restart_scheduled = [False]
  576. _real_create_task = asyncio.create_task
  577. def tracking_create_task(coro, *, name=None):
  578. if name and "cert_restart" in name:
  579. restart_scheduled[0] = True
  580. coro.close()
  581. # Return a dummy completed task
  582. fut = asyncio.get_event_loop().create_future()
  583. fut.set_result(None)
  584. return fut
  585. return _real_create_task(coro, name=name)
  586. with (
  587. patch("asyncio.sleep", new_callable=AsyncMock),
  588. patch.object(asyncio, "create_task", side_effect=tracking_create_task),
  589. patch("backend.app.services.virtual_printer.manager.tailscale_service") as mock_ts,
  590. patch.object(
  591. instance._cert_service,
  592. "use_tailscale_cert",
  593. new_callable=AsyncMock,
  594. return_value=(instance._cert_service.ts_cert_path, instance._cert_service.ts_key_path),
  595. ),
  596. ):
  597. mock_ts.cert_needs_renewal.return_value = True
  598. # Run the loop directly; it exits via break after scheduling the restart
  599. task = _real_create_task(instance._cert_renewal_loop())
  600. await task
  601. assert restart_scheduled[0] is True
  602. class TestCancelRestartTaskSelfAwait:
  603. """Regression: _cancel_restart_task must not await the current task.
  604. stop_server() / stop_proxy() are called from inside _restart_for_cert_renewal,
  605. which runs AS _cert_restart_task. Cancelling+awaiting self would flag a
  606. CancelledError on the next `await`, tearing down the old listeners but
  607. never letting start_server run — the VP would stay on the old/expired cert
  608. until the process restarts.
  609. """
  610. def _make_instance(self, tmp_path):
  611. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  612. return VirtualPrinterInstance(
  613. vp_id=1,
  614. name="TestVP",
  615. mode="immediate",
  616. model="C11",
  617. access_code="12345678",
  618. serial_suffix="391800001",
  619. tailscale_disabled=False,
  620. base_dir=tmp_path,
  621. )
  622. @pytest.mark.asyncio
  623. async def test_cancel_from_inside_own_task_does_not_cancel_self(self, tmp_path):
  624. """When _cancel_restart_task is called from inside the restart task itself,
  625. it clears the reference without cancelling — subsequent awaits must succeed."""
  626. instance = self._make_instance(tmp_path)
  627. completed_to_end = [False]
  628. async def fake_restart():
  629. # Simulate stop_server calling _cancel_restart_task from inside the restart task.
  630. await instance._cancel_restart_task()
  631. # If _cancel_restart_task had self-awaited, the next `await` would raise
  632. # CancelledError and this line would never be reached.
  633. await asyncio.sleep(0)
  634. completed_to_end[0] = True
  635. task = asyncio.create_task(fake_restart(), name="cert_restart")
  636. instance._cert_restart_task = task
  637. await task
  638. assert completed_to_end[0] is True
  639. assert instance._cert_restart_task is None
  640. @pytest.mark.asyncio
  641. async def test_cancel_from_outside_still_cancels_and_awaits(self, tmp_path):
  642. """Non-self callers must retain the original cancel-and-await behaviour."""
  643. instance = self._make_instance(tmp_path)
  644. started = asyncio.Event()
  645. async def long_restart():
  646. started.set()
  647. try:
  648. await asyncio.sleep(10)
  649. except asyncio.CancelledError:
  650. raise
  651. task = asyncio.create_task(long_restart(), name="cert_restart")
  652. instance._cert_restart_task = task
  653. await started.wait()
  654. # Cancel from an outside coroutine — this should actually cancel the task.
  655. await instance._cancel_restart_task()
  656. assert task.cancelled()
  657. assert instance._cert_restart_task is None