test_virtual_printer.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. """Unit tests for Virtual Printer services.
  2. Tests the virtual printer manager, FTP server, and SSDP server components.
  3. """
  4. import asyncio
  5. from pathlib import Path
  6. from unittest.mock import AsyncMock, MagicMock, patch
  7. import pytest
  8. class TestVirtualPrinterManager:
  9. """Tests for VirtualPrinterManager class."""
  10. @pytest.fixture
  11. def manager(self):
  12. """Create a VirtualPrinterManager instance."""
  13. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  14. return VirtualPrinterManager()
  15. # ========================================================================
  16. # Tests for configuration
  17. # ========================================================================
  18. @pytest.mark.asyncio
  19. async def test_configure_sets_parameters(self, manager):
  20. """Verify configure stores parameters correctly."""
  21. # Mock the start/stop methods to avoid actually starting services
  22. manager._start = AsyncMock()
  23. await manager.configure(
  24. enabled=True,
  25. access_code="12345678",
  26. mode="immediate",
  27. )
  28. assert manager._enabled is True
  29. assert manager._access_code == "12345678"
  30. assert manager._mode == "immediate"
  31. @pytest.mark.asyncio
  32. async def test_configure_disabled_stops_services(self, manager):
  33. """Verify disabling stops all services."""
  34. # First simulate enabled state
  35. manager._enabled = True
  36. manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
  37. manager._stop = AsyncMock()
  38. await manager.configure(enabled=False, access_code="12345678")
  39. assert manager._enabled is False
  40. manager._stop.assert_called_once()
  41. @pytest.mark.asyncio
  42. async def test_configure_requires_access_code_when_enabling(self, manager):
  43. """Verify access code is required when enabling."""
  44. with pytest.raises(ValueError, match="Access code is required"):
  45. await manager.configure(enabled=True)
  46. # ========================================================================
  47. # Tests for status
  48. # ========================================================================
  49. def test_get_status_returns_correct_format(self, manager):
  50. """Verify get_status returns expected fields."""
  51. manager._enabled = True
  52. manager._mode = "immediate"
  53. manager._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
  54. # Simulate running tasks
  55. manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
  56. status = manager.get_status()
  57. assert status["enabled"] is True
  58. assert status["running"] is True
  59. assert status["mode"] == "immediate"
  60. assert status["name"] == "Bambuddy"
  61. assert status["serial"] == "00M09A391800001"
  62. assert status["pending_files"] == 1
  63. def test_get_status_when_stopped(self, manager):
  64. """Verify get_status when not running."""
  65. manager._enabled = False
  66. manager._tasks = []
  67. status = manager.get_status()
  68. assert status["enabled"] is False
  69. assert status["running"] is False
  70. def test_is_running_with_active_tasks(self, manager):
  71. """Verify is_running is True when tasks are active."""
  72. mock_task = MagicMock()
  73. mock_task.done.return_value = False
  74. manager._tasks = [mock_task]
  75. assert manager.is_running is True
  76. def test_is_running_with_no_tasks(self, manager):
  77. """Verify is_running is False when no tasks."""
  78. manager._tasks = []
  79. assert manager.is_running is False
  80. # ========================================================================
  81. # Tests for file handling
  82. # ========================================================================
  83. @pytest.mark.asyncio
  84. async def test_on_file_received_adds_to_pending(self, manager):
  85. """Verify received file is added to pending list."""
  86. manager._mode = "queue"
  87. manager._session_factory = None # Disable actual archiving
  88. file_path = Path("/tmp/test.3mf")
  89. with patch.object(manager, "_queue_file", new_callable=AsyncMock) as mock_queue:
  90. await manager._on_file_received(file_path, "192.168.1.100")
  91. assert "test.3mf" in manager._pending_files
  92. mock_queue.assert_called_once()
  93. @pytest.mark.asyncio
  94. async def test_on_file_received_archives_immediately(self, manager):
  95. """Verify file is archived in immediate mode."""
  96. manager._mode = "immediate"
  97. manager._session_factory = None # Will prevent actual archiving
  98. file_path = Path("/tmp/test.3mf")
  99. with patch.object(manager, "_archive_file", new_callable=AsyncMock) as mock_archive:
  100. await manager._on_file_received(file_path, "192.168.1.100")
  101. mock_archive.assert_called_once_with(file_path, "192.168.1.100")
  102. @pytest.mark.asyncio
  103. async def test_archive_file_skips_non_3mf(self, manager):
  104. """Verify non-3MF files are skipped and cleaned up."""
  105. manager._session_factory = MagicMock()
  106. manager._pending_files["verify_job"] = Path("/tmp/verify_job")
  107. with patch("pathlib.Path.unlink"):
  108. await manager._archive_file(Path("/tmp/verify_job"), "192.168.1.100")
  109. # Should be removed from pending
  110. assert "verify_job" not in manager._pending_files
  111. class TestFTPSession:
  112. """Tests for FTP session handling."""
  113. @pytest.fixture
  114. def mock_reader(self):
  115. """Create a mock StreamReader."""
  116. reader = AsyncMock()
  117. return reader
  118. @pytest.fixture
  119. def mock_writer(self):
  120. """Create a mock StreamWriter."""
  121. writer = MagicMock()
  122. writer.get_extra_info = MagicMock(return_value=("192.168.1.100", 12345))
  123. writer.write = MagicMock()
  124. writer.drain = AsyncMock()
  125. writer.close = MagicMock()
  126. writer.wait_closed = AsyncMock()
  127. writer.is_closing = MagicMock(return_value=False)
  128. return writer
  129. @pytest.fixture
  130. def ssl_context(self):
  131. """Create a mock SSL context."""
  132. return MagicMock()
  133. @pytest.fixture
  134. def session(self, mock_reader, mock_writer, ssl_context, tmp_path):
  135. """Create an FTPSession instance."""
  136. from backend.app.services.virtual_printer.ftp_server import FTPSession
  137. return FTPSession(
  138. reader=mock_reader,
  139. writer=mock_writer,
  140. upload_dir=tmp_path,
  141. access_code="12345678",
  142. ssl_context=ssl_context,
  143. on_file_received=None,
  144. )
  145. # ========================================================================
  146. # Tests for authentication
  147. # ========================================================================
  148. @pytest.mark.asyncio
  149. async def test_user_command_accepts_bblp(self, session):
  150. """Verify USER command accepts bblp user."""
  151. await session.cmd_USER("bblp")
  152. assert session.username == "bblp"
  153. @pytest.mark.asyncio
  154. async def test_pass_command_authenticates(self, session):
  155. """Verify PASS command authenticates with correct code."""
  156. session.username = "bblp"
  157. await session.cmd_PASS("12345678")
  158. assert session.authenticated is True
  159. @pytest.mark.asyncio
  160. async def test_pass_command_rejects_wrong_code(self, session):
  161. """Verify PASS command rejects wrong access code."""
  162. session.username = "bblp"
  163. await session.cmd_PASS("wrongcode")
  164. assert session.authenticated is False
  165. # ========================================================================
  166. # Tests for FTP commands
  167. # ========================================================================
  168. @pytest.mark.asyncio
  169. async def test_syst_command(self, session):
  170. """Verify SYST returns UNIX type."""
  171. await session.cmd_SYST("")
  172. session.writer.write.assert_called()
  173. call_args = session.writer.write.call_args[0][0].decode()
  174. assert "215" in call_args
  175. assert "UNIX" in call_args
  176. @pytest.mark.asyncio
  177. async def test_pwd_command_requires_auth(self, session):
  178. """Verify PWD requires authentication."""
  179. session.authenticated = False
  180. await session.cmd_PWD("")
  181. call_args = session.writer.write.call_args[0][0].decode()
  182. assert "530" in call_args
  183. @pytest.mark.asyncio
  184. async def test_pwd_command_when_authenticated(self, session):
  185. """Verify PWD returns root directory when authenticated."""
  186. session.authenticated = True
  187. await session.cmd_PWD("")
  188. call_args = session.writer.write.call_args[0][0].decode()
  189. assert "257" in call_args
  190. @pytest.mark.asyncio
  191. async def test_type_command_sets_binary(self, session):
  192. """Verify TYPE I sets binary mode."""
  193. session.authenticated = True
  194. await session.cmd_TYPE("I")
  195. assert session.transfer_type == "I"
  196. @pytest.mark.asyncio
  197. async def test_pbsz_command(self, session):
  198. """Verify PBSZ returns success."""
  199. await session.cmd_PBSZ("0")
  200. call_args = session.writer.write.call_args[0][0].decode()
  201. assert "200" in call_args
  202. @pytest.mark.asyncio
  203. async def test_prot_command_accepts_p(self, session):
  204. """Verify PROT P is accepted."""
  205. await session.cmd_PROT("P")
  206. call_args = session.writer.write.call_args[0][0].decode()
  207. assert "200" in call_args
  208. @pytest.mark.asyncio
  209. async def test_quit_command(self, session):
  210. """Verify QUIT sends goodbye and raises CancelledError."""
  211. with pytest.raises(asyncio.CancelledError):
  212. await session.cmd_QUIT("")
  213. class TestSSDPServer:
  214. """Tests for Virtual Printer SSDP server."""
  215. @pytest.fixture
  216. def ssdp_server(self):
  217. """Create a VirtualPrinterSSDPServer instance."""
  218. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  219. return VirtualPrinterSSDPServer(
  220. serial="TEST123",
  221. name="TestPrinter",
  222. model="BL-P001",
  223. )
  224. # ========================================================================
  225. # Tests for SSDP response
  226. # ========================================================================
  227. def test_build_notify_message(self, ssdp_server):
  228. """Verify NOTIFY packet contains required headers."""
  229. # Set a known IP for testing
  230. ssdp_server._local_ip = "192.168.1.100"
  231. message = ssdp_server._build_notify_message()
  232. assert b"NOTIFY" in message
  233. assert b"DevName.bambu.com: TestPrinter" in message
  234. assert b"USN: TEST123" in message
  235. def test_build_response_message(self, ssdp_server):
  236. """Verify response packet contains required headers."""
  237. # Set a known IP for testing
  238. ssdp_server._local_ip = "192.168.1.100"
  239. message = ssdp_server._build_response_message()
  240. assert b"HTTP/1.1 200 OK" in message
  241. assert b"DevName.bambu.com: TestPrinter" in message
  242. assert b"USN: TEST123" in message
  243. def test_ssdp_server_uses_correct_model(self, ssdp_server):
  244. """Verify SSDP server uses the provided model."""
  245. ssdp_server._local_ip = "192.168.1.100"
  246. message = ssdp_server._build_notify_message()
  247. assert b"DevModel.bambu.com: BL-P001" in message
  248. class TestCertificateService:
  249. """Tests for TLS certificate generation."""
  250. @pytest.fixture
  251. def cert_service(self, tmp_path):
  252. """Create a CertificateService instance."""
  253. from backend.app.services.virtual_printer.certificate import CertificateService
  254. return CertificateService(cert_dir=tmp_path, serial="TEST123")
  255. def test_generate_certificates(self, cert_service, tmp_path):
  256. """Verify certificates are generated correctly."""
  257. cert_path, key_path = cert_service.generate_certificates()
  258. assert cert_path.exists()
  259. assert key_path.exists()
  260. # Verify certificate content
  261. cert_content = cert_path.read_text()
  262. assert "BEGIN CERTIFICATE" in cert_content
  263. key_content = key_path.read_text()
  264. assert "BEGIN" in key_content and "KEY" in key_content
  265. def test_certificates_reused_if_exist(self, cert_service):
  266. """Verify existing certificates are reused."""
  267. # First generation
  268. cert_path1, key_path1 = cert_service.generate_certificates()
  269. mtime1 = cert_path1.stat().st_mtime
  270. # Second call should reuse (via ensure_certificates)
  271. cert_path2, key_path2 = cert_service.ensure_certificates()
  272. mtime2 = cert_path2.stat().st_mtime
  273. assert mtime1 == mtime2 # File wasn't regenerated
  274. def test_delete_certificates(self, cert_service):
  275. """Verify certificates can be deleted."""
  276. cert_service.generate_certificates()
  277. assert cert_service.cert_path.exists()
  278. assert cert_service.key_path.exists()
  279. cert_service.delete_certificates()
  280. assert not cert_service.cert_path.exists()
  281. assert not cert_service.key_path.exists()
  282. def test_ensure_creates_if_not_exist(self, cert_service):
  283. """Verify ensure_certificates generates if not existing."""
  284. assert not cert_service.cert_path.exists()
  285. cert_path, key_path = cert_service.ensure_certificates()
  286. assert cert_path.exists()
  287. assert key_path.exists()