test_tailscale.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. """Unit tests for TailscaleService — presence detection only.
  2. Cert provisioning was removed: BambuStudio's printer-MQTT trust path validates
  3. against its bundled BBL CA, not the system trust store, so a Tailscale-issued
  4. LE cert was rejected regardless of hostname/IP. The Tailscale toggle is now
  5. informational (surfacing the host's Tailscale IP/FQDN to guide the user).
  6. """
  7. import json
  8. from unittest.mock import AsyncMock, patch
  9. import pytest
  10. class TestTailscaleService:
  11. """Tests for TailscaleService CLI wrapper — get_status only."""
  12. @pytest.mark.asyncio
  13. async def test_get_status_binary_not_found(self):
  14. """Returns available=False when the tailscale binary is absent from PATH."""
  15. from backend.app.services.virtual_printer.tailscale import TailscaleService
  16. svc = TailscaleService()
  17. with patch("shutil.which", return_value=None):
  18. status = await svc.get_status()
  19. assert status.available is False
  20. assert status.error is not None
  21. assert "not found" in status.error
  22. @pytest.mark.asyncio
  23. async def test_get_status_command_fails(self):
  24. """Returns available=False when `tailscale status` exits non-zero."""
  25. from backend.app.services.virtual_printer.tailscale import TailscaleService
  26. svc = TailscaleService()
  27. with (
  28. patch("shutil.which", return_value="/usr/bin/tailscale"),
  29. patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(1, b"", b"permission denied")),
  30. ):
  31. status = await svc.get_status()
  32. assert status.available is False
  33. assert "permission denied" in (status.error or "")
  34. @pytest.mark.asyncio
  35. async def test_get_status_success(self):
  36. """Parses FQDN, hostname, tailnet_name, and IP list from JSON output."""
  37. from backend.app.services.virtual_printer.tailscale import TailscaleService
  38. payload = {
  39. "Self": {
  40. "DNSName": "myhost.example.ts.net.",
  41. "TailscaleIPs": ["100.1.2.3", "fd7a::1"],
  42. }
  43. }
  44. svc = TailscaleService()
  45. with (
  46. patch("shutil.which", return_value="/usr/bin/tailscale"),
  47. patch.object(
  48. svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, json.dumps(payload).encode(), b"")
  49. ),
  50. ):
  51. status = await svc.get_status()
  52. assert status.available is True
  53. assert status.fqdn == "myhost.example.ts.net"
  54. assert status.hostname == "myhost"
  55. assert status.tailnet_name == "example.ts.net"
  56. assert "100.1.2.3" in status.tailscale_ips
  57. @pytest.mark.asyncio
  58. async def test_get_status_empty_dnsname(self):
  59. """Returns available=False when Tailscale daemon reports no DNSName (not connected)."""
  60. from backend.app.services.virtual_printer.tailscale import TailscaleService
  61. payload = {"Self": {"DNSName": "", "TailscaleIPs": []}}
  62. svc = TailscaleService()
  63. with (
  64. patch("shutil.which", return_value="/usr/bin/tailscale"),
  65. patch.object(
  66. svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, json.dumps(payload).encode(), b"")
  67. ),
  68. ):
  69. status = await svc.get_status()
  70. assert status.available is False
  71. assert "no DNSName" in (status.error or "")
  72. @pytest.mark.asyncio
  73. async def test_get_status_malformed_json(self):
  74. """Returns available=False with a parse-error reason when stdout is not JSON."""
  75. from backend.app.services.virtual_printer.tailscale import TailscaleService
  76. svc = TailscaleService()
  77. with (
  78. patch("shutil.which", return_value="/usr/bin/tailscale"),
  79. patch.object(svc, "_run_tailscale", new_callable=AsyncMock, return_value=(0, b"not-json{", b"")),
  80. ):
  81. status = await svc.get_status()
  82. assert status.available is False
  83. assert "JSON parse error" in (status.error or "")