test_virtual_printer.py 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235
  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. @pytest.mark.asyncio
  47. async def test_configure_sets_model(self, manager):
  48. """Verify configure stores model correctly."""
  49. manager._start = AsyncMock()
  50. await manager.configure(
  51. enabled=True,
  52. access_code="12345678",
  53. mode="immediate",
  54. model="C11", # P1S model code
  55. )
  56. assert manager._model == "C11"
  57. @pytest.mark.asyncio
  58. async def test_configure_ignores_invalid_model(self, manager):
  59. """Verify configure ignores invalid model codes."""
  60. manager._start = AsyncMock()
  61. await manager.configure(
  62. enabled=True,
  63. access_code="12345678",
  64. model="INVALID",
  65. )
  66. # Should keep default model (3DPrinter-X1-Carbon = X1C)
  67. assert manager._model == "3DPrinter-X1-Carbon"
  68. @pytest.mark.asyncio
  69. async def test_configure_restarts_on_model_change(self, manager):
  70. """Verify model change restarts services when running."""
  71. # Simulate running state
  72. manager._enabled = True
  73. manager._model = "3DPrinter-X1-Carbon"
  74. manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
  75. manager._stop = AsyncMock()
  76. manager._start = AsyncMock()
  77. await manager.configure(
  78. enabled=True,
  79. access_code="12345678",
  80. model="C11", # P1P
  81. )
  82. # Should have stopped and started
  83. manager._stop.assert_called_once()
  84. manager._start.assert_called_once()
  85. # ========================================================================
  86. # Tests for status
  87. # ========================================================================
  88. def test_get_status_returns_correct_format(self, manager):
  89. """Verify get_status returns expected fields."""
  90. manager._enabled = True
  91. manager._mode = "immediate"
  92. manager._model = "C11" # P1P
  93. manager._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
  94. # Simulate running tasks
  95. manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
  96. status = manager.get_status()
  97. assert status["enabled"] is True
  98. assert status["running"] is True
  99. assert status["mode"] == "immediate"
  100. assert status["name"] == "Bambuddy"
  101. assert status["serial"] == "01S00A391800001" # C11 (P1P) serial prefix
  102. assert status["model"] == "C11"
  103. assert status["model_name"] == "P1P"
  104. assert status["pending_files"] == 1
  105. def test_get_status_when_stopped(self, manager):
  106. """Verify get_status when not running."""
  107. manager._enabled = False
  108. manager._tasks = []
  109. status = manager.get_status()
  110. assert status["enabled"] is False
  111. assert status["running"] is False
  112. def test_is_running_with_active_tasks(self, manager):
  113. """Verify is_running is True when tasks are active."""
  114. mock_task = MagicMock()
  115. mock_task.done.return_value = False
  116. manager._tasks = [mock_task]
  117. assert manager.is_running is True
  118. def test_is_running_with_no_tasks(self, manager):
  119. """Verify is_running is False when no tasks."""
  120. manager._tasks = []
  121. assert manager.is_running is False
  122. # ========================================================================
  123. # Tests for file handling
  124. # ========================================================================
  125. @pytest.mark.asyncio
  126. async def test_on_file_received_adds_to_pending(self, manager):
  127. """Verify received file is added to pending list."""
  128. manager._mode = "queue"
  129. manager._session_factory = None # Disable actual archiving
  130. file_path = Path("/tmp/test.3mf")
  131. with patch.object(manager, "_queue_file", new_callable=AsyncMock) as mock_queue:
  132. await manager._on_file_received(file_path, "192.168.1.100")
  133. assert "test.3mf" in manager._pending_files
  134. mock_queue.assert_called_once()
  135. @pytest.mark.asyncio
  136. async def test_on_file_received_archives_immediately(self, manager):
  137. """Verify file is archived in immediate mode."""
  138. manager._mode = "immediate"
  139. manager._session_factory = None # Will prevent actual archiving
  140. file_path = Path("/tmp/test.3mf")
  141. with patch.object(manager, "_archive_file", new_callable=AsyncMock) as mock_archive:
  142. await manager._on_file_received(file_path, "192.168.1.100")
  143. mock_archive.assert_called_once_with(file_path, "192.168.1.100")
  144. @pytest.mark.asyncio
  145. async def test_archive_file_skips_non_3mf(self, manager):
  146. """Verify non-3MF files are skipped and cleaned up."""
  147. manager._session_factory = MagicMock()
  148. manager._pending_files["verify_job"] = Path("/tmp/verify_job")
  149. with patch("pathlib.Path.unlink"):
  150. await manager._archive_file(Path("/tmp/verify_job"), "192.168.1.100")
  151. # Should be removed from pending
  152. assert "verify_job" not in manager._pending_files
  153. class TestFTPSession:
  154. """Tests for FTP session handling."""
  155. @pytest.fixture
  156. def mock_reader(self):
  157. """Create a mock StreamReader."""
  158. reader = AsyncMock()
  159. return reader
  160. @pytest.fixture
  161. def mock_writer(self):
  162. """Create a mock StreamWriter."""
  163. writer = MagicMock()
  164. writer.get_extra_info = MagicMock(return_value=("192.168.1.100", 12345))
  165. writer.write = MagicMock()
  166. writer.drain = AsyncMock()
  167. writer.close = MagicMock()
  168. writer.wait_closed = AsyncMock()
  169. writer.is_closing = MagicMock(return_value=False)
  170. return writer
  171. @pytest.fixture
  172. def ssl_context(self):
  173. """Create a mock SSL context."""
  174. return MagicMock()
  175. @pytest.fixture
  176. def session(self, mock_reader, mock_writer, ssl_context, tmp_path):
  177. """Create an FTPSession instance."""
  178. from backend.app.services.virtual_printer.ftp_server import FTPSession
  179. return FTPSession(
  180. reader=mock_reader,
  181. writer=mock_writer,
  182. upload_dir=tmp_path,
  183. access_code="12345678",
  184. ssl_context=ssl_context,
  185. on_file_received=None,
  186. )
  187. # ========================================================================
  188. # Tests for authentication
  189. # ========================================================================
  190. @pytest.mark.asyncio
  191. async def test_user_command_accepts_bblp(self, session):
  192. """Verify USER command accepts bblp user."""
  193. await session.cmd_USER("bblp")
  194. assert session.username == "bblp"
  195. @pytest.mark.asyncio
  196. async def test_pass_command_authenticates(self, session):
  197. """Verify PASS command authenticates with correct code."""
  198. session.username = "bblp"
  199. await session.cmd_PASS("12345678")
  200. assert session.authenticated is True
  201. @pytest.mark.asyncio
  202. async def test_pass_command_rejects_wrong_code(self, session):
  203. """Verify PASS command rejects wrong access code."""
  204. session.username = "bblp"
  205. await session.cmd_PASS("wrongcode")
  206. assert session.authenticated is False
  207. # ========================================================================
  208. # Tests for FTP commands
  209. # ========================================================================
  210. @pytest.mark.asyncio
  211. async def test_syst_command(self, session):
  212. """Verify SYST returns UNIX type."""
  213. await session.cmd_SYST("")
  214. session.writer.write.assert_called()
  215. call_args = session.writer.write.call_args[0][0].decode()
  216. assert "215" in call_args
  217. assert "UNIX" in call_args
  218. @pytest.mark.asyncio
  219. async def test_pwd_command_requires_auth(self, session):
  220. """Verify PWD requires authentication."""
  221. session.authenticated = False
  222. await session.cmd_PWD("")
  223. call_args = session.writer.write.call_args[0][0].decode()
  224. assert "530" in call_args
  225. @pytest.mark.asyncio
  226. async def test_pwd_command_when_authenticated(self, session):
  227. """Verify PWD returns root directory when authenticated."""
  228. session.authenticated = True
  229. await session.cmd_PWD("")
  230. call_args = session.writer.write.call_args[0][0].decode()
  231. assert "257" in call_args
  232. @pytest.mark.asyncio
  233. async def test_type_command_sets_binary(self, session):
  234. """Verify TYPE I sets binary mode."""
  235. session.authenticated = True
  236. await session.cmd_TYPE("I")
  237. assert session.transfer_type == "I"
  238. @pytest.mark.asyncio
  239. async def test_pbsz_command(self, session):
  240. """Verify PBSZ returns success."""
  241. await session.cmd_PBSZ("0")
  242. call_args = session.writer.write.call_args[0][0].decode()
  243. assert "200" in call_args
  244. @pytest.mark.asyncio
  245. async def test_prot_command_accepts_p(self, session):
  246. """Verify PROT P is accepted."""
  247. await session.cmd_PROT("P")
  248. call_args = session.writer.write.call_args[0][0].decode()
  249. assert "200" in call_args
  250. @pytest.mark.asyncio
  251. async def test_quit_command(self, session):
  252. """Verify QUIT sends goodbye and raises CancelledError."""
  253. with pytest.raises(asyncio.CancelledError):
  254. await session.cmd_QUIT("")
  255. class TestSSDPServer:
  256. """Tests for Virtual Printer SSDP server."""
  257. @pytest.fixture
  258. def ssdp_server(self):
  259. """Create a VirtualPrinterSSDPServer instance."""
  260. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  261. return VirtualPrinterSSDPServer(
  262. serial="TEST123",
  263. name="TestPrinter",
  264. model="BL-P001",
  265. )
  266. # ========================================================================
  267. # Tests for SSDP response
  268. # ========================================================================
  269. def test_build_notify_message(self, ssdp_server):
  270. """Verify NOTIFY packet contains required headers."""
  271. # Set a known IP for testing
  272. ssdp_server._local_ip = "192.168.1.100"
  273. message = ssdp_server._build_notify_message()
  274. assert b"NOTIFY" in message
  275. assert b"DevName.bambu.com: TestPrinter" in message
  276. assert b"USN: TEST123" in message
  277. def test_build_response_message(self, ssdp_server):
  278. """Verify response packet contains required headers."""
  279. # Set a known IP for testing
  280. ssdp_server._local_ip = "192.168.1.100"
  281. message = ssdp_server._build_response_message()
  282. assert b"HTTP/1.1 200 OK" in message
  283. assert b"DevName.bambu.com: TestPrinter" in message
  284. assert b"USN: TEST123" in message
  285. def test_ssdp_server_uses_correct_model(self, ssdp_server):
  286. """Verify SSDP server uses the provided model."""
  287. ssdp_server._local_ip = "192.168.1.100"
  288. message = ssdp_server._build_notify_message()
  289. assert b"DevModel.bambu.com: BL-P001" in message
  290. # ========================================================================
  291. # Tests for advertise_ip parameter
  292. # ========================================================================
  293. def test_advertise_ip_sets_local_ip(self):
  294. """Verify advertise_ip overrides auto-detection."""
  295. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  296. server = VirtualPrinterSSDPServer(
  297. serial="TEST123",
  298. name="TestPrinter",
  299. model="BL-P001",
  300. advertise_ip="10.0.0.50",
  301. )
  302. assert server._local_ip == "10.0.0.50"
  303. def test_advertise_ip_empty_string_uses_auto_detect(self):
  304. """Verify empty advertise_ip falls back to auto-detection."""
  305. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  306. server = VirtualPrinterSSDPServer(
  307. serial="TEST123",
  308. name="TestPrinter",
  309. model="BL-P001",
  310. advertise_ip="",
  311. )
  312. assert server._local_ip is None
  313. def test_advertise_ip_in_notify_message(self):
  314. """Verify NOTIFY message uses the advertise_ip."""
  315. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  316. server = VirtualPrinterSSDPServer(
  317. serial="TEST123",
  318. name="TestPrinter",
  319. model="BL-P001",
  320. advertise_ip="10.0.0.50",
  321. )
  322. message = server._build_notify_message()
  323. assert b"Location: 10.0.0.50" in message
  324. def test_advertise_ip_in_response_message(self):
  325. """Verify M-SEARCH response uses the advertise_ip."""
  326. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  327. server = VirtualPrinterSSDPServer(
  328. serial="TEST123",
  329. name="TestPrinter",
  330. model="BL-P001",
  331. advertise_ip="10.0.0.50",
  332. )
  333. message = server._build_response_message()
  334. assert b"Location: 10.0.0.50" in message
  335. def test_default_no_advertise_ip(self):
  336. """Verify default constructor has None local_ip (auto-detect)."""
  337. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  338. server = VirtualPrinterSSDPServer()
  339. assert server._local_ip is None
  340. class TestCertificateService:
  341. """Tests for TLS certificate generation."""
  342. @pytest.fixture
  343. def cert_service(self, tmp_path):
  344. """Create a CertificateService instance."""
  345. from backend.app.services.virtual_printer.certificate import CertificateService
  346. return CertificateService(cert_dir=tmp_path, serial="TEST123")
  347. def test_generate_certificates(self, cert_service, tmp_path):
  348. """Verify certificates are generated correctly."""
  349. cert_path, key_path = cert_service.generate_certificates()
  350. assert cert_path.exists()
  351. assert key_path.exists()
  352. # Verify certificate content
  353. cert_content = cert_path.read_text()
  354. assert "BEGIN CERTIFICATE" in cert_content
  355. key_content = key_path.read_text()
  356. assert "BEGIN" in key_content and "KEY" in key_content
  357. def test_certificates_reused_if_exist(self, cert_service):
  358. """Verify existing certificates are reused."""
  359. # First generation
  360. cert_path1, key_path1 = cert_service.generate_certificates()
  361. mtime1 = cert_path1.stat().st_mtime
  362. # Second call should reuse (via ensure_certificates)
  363. cert_path2, key_path2 = cert_service.ensure_certificates()
  364. mtime2 = cert_path2.stat().st_mtime
  365. assert mtime1 == mtime2 # File wasn't regenerated
  366. def test_delete_certificates(self, cert_service):
  367. """Verify certificates can be deleted."""
  368. cert_service.generate_certificates()
  369. assert cert_service.cert_path.exists()
  370. assert cert_service.key_path.exists()
  371. cert_service.delete_certificates()
  372. assert not cert_service.cert_path.exists()
  373. assert not cert_service.key_path.exists()
  374. def test_ensure_creates_if_not_exist(self, cert_service):
  375. """Verify ensure_certificates generates if not existing."""
  376. assert not cert_service.cert_path.exists()
  377. cert_path, key_path = cert_service.ensure_certificates()
  378. assert cert_path.exists()
  379. assert key_path.exists()
  380. class TestBindServer:
  381. """Tests for BindServer (port 3000 bind/detect protocol)."""
  382. @pytest.fixture
  383. def bind_server(self):
  384. """Create a BindServer instance."""
  385. from backend.app.services.virtual_printer.bind_server import BindServer
  386. return BindServer(
  387. serial="09400A391800001",
  388. model="O1D",
  389. name="Bambuddy",
  390. )
  391. def test_build_frame(self, bind_server):
  392. """Verify frame building produces correct format."""
  393. payload = {"login": {"command": "detect"}}
  394. frame = bind_server._build_frame(payload)
  395. # Header: 0xA5A5
  396. assert frame[:2] == b"\xa5\xa5"
  397. # Trailer: 0xA7A7
  398. assert frame[-2:] == b"\xa7\xa7"
  399. # Length field is total message size (LE uint16)
  400. import struct
  401. total_len = struct.unpack_from("<H", frame, 2)[0]
  402. assert total_len == len(frame)
  403. # JSON payload is between header and trailer
  404. import json
  405. json_bytes = frame[4:-2]
  406. parsed = json.loads(json_bytes)
  407. assert parsed == payload
  408. def test_parse_frame_valid(self, bind_server):
  409. """Verify valid frame parsing extracts JSON correctly."""
  410. import json
  411. import struct
  412. payload = {"login": {"command": "detect", "sequence_id": "20000"}}
  413. json_bytes = json.dumps(payload, separators=(",", ":")).encode()
  414. total_len = 4 + len(json_bytes) + 2
  415. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + json_bytes + b"\xa7\xa7"
  416. result = bind_server._parse_frame(frame)
  417. assert result is not None
  418. assert result["login"]["command"] == "detect"
  419. assert result["login"]["sequence_id"] == "20000"
  420. def test_parse_frame_invalid_header(self, bind_server):
  421. """Verify invalid header returns None."""
  422. result = bind_server._parse_frame(b"\xbb\xbb\x06\x00{}\xa7\xa7")
  423. assert result is None
  424. def test_parse_frame_invalid_trailer(self, bind_server):
  425. """Verify invalid trailer returns None."""
  426. result = bind_server._parse_frame(b"\xa5\xa5\x06\x00{}\xbb\xbb")
  427. assert result is None
  428. def test_parse_frame_too_short(self, bind_server):
  429. """Verify short data returns None."""
  430. result = bind_server._parse_frame(b"\xa5\xa5\x00")
  431. assert result is None
  432. def test_parse_frame_invalid_json(self, bind_server):
  433. """Verify invalid JSON returns None."""
  434. import struct
  435. bad_json = b"not json"
  436. total_len = 4 + len(bad_json) + 2
  437. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
  438. result = bind_server._parse_frame(frame)
  439. assert result is None
  440. def test_build_frame_roundtrip(self, bind_server):
  441. """Verify build_frame output can be parsed back."""
  442. payload = {
  443. "login": {
  444. "bind": "free",
  445. "command": "detect",
  446. "connect": "lan",
  447. "dev_cap": 1,
  448. "id": "09400A391800001",
  449. "model": "O1D",
  450. "name": "Bambuddy",
  451. "sequence_id": 3021,
  452. "version": "01.00.00.00",
  453. }
  454. }
  455. frame = bind_server._build_frame(payload)
  456. parsed = bind_server._parse_frame(frame)
  457. assert parsed is not None
  458. assert parsed["login"]["id"] == "09400A391800001"
  459. assert parsed["login"]["model"] == "O1D"
  460. assert parsed["login"]["name"] == "Bambuddy"
  461. assert parsed["login"]["bind"] == "free"
  462. def test_bind_server_stores_config(self, bind_server):
  463. """Verify bind server stores serial, model, name."""
  464. assert bind_server.serial == "09400A391800001"
  465. assert bind_server.model == "O1D"
  466. assert bind_server.name == "Bambuddy"
  467. assert bind_server.version == "01.00.00.00"
  468. def test_bind_server_custom_version(self):
  469. """Verify custom firmware version is stored."""
  470. from backend.app.services.virtual_printer.bind_server import BindServer
  471. server = BindServer(
  472. serial="TEST123",
  473. model="C13",
  474. name="Test",
  475. version="02.03.04.05",
  476. )
  477. assert server.version == "02.03.04.05"
  478. class TestSlicerProxyManager:
  479. """Tests for SlicerProxyManager (proxy mode)."""
  480. @pytest.fixture
  481. def proxy_manager(self, tmp_path):
  482. """Create a SlicerProxyManager instance."""
  483. from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
  484. # Create dummy cert files
  485. cert_path = tmp_path / "cert.pem"
  486. key_path = tmp_path / "key.pem"
  487. cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
  488. # Split string to avoid pre-commit hook false positive on test data
  489. key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
  490. return SlicerProxyManager(
  491. target_host="192.168.1.100",
  492. cert_path=cert_path,
  493. key_path=key_path,
  494. )
  495. def test_proxy_manager_initializes_ports(self, proxy_manager):
  496. """Verify proxy manager has correct port constants."""
  497. # FTP proxy uses privileged port 990 to match what Bambu Studio expects
  498. assert proxy_manager.LOCAL_FTP_PORT == 990
  499. assert proxy_manager.LOCAL_MQTT_PORT == 8883
  500. assert proxy_manager.PRINTER_FTP_PORT == 990
  501. assert proxy_manager.PRINTER_MQTT_PORT == 8883
  502. def test_proxy_manager_stores_target_host(self, proxy_manager):
  503. """Verify proxy manager stores target host."""
  504. assert proxy_manager.target_host == "192.168.1.100"
  505. def test_get_status_before_start(self, proxy_manager):
  506. """Verify get_status returns zeros before start."""
  507. status = proxy_manager.get_status()
  508. assert status["running"] is False
  509. assert status["ftp_connections"] == 0
  510. assert status["mqtt_connections"] == 0
  511. class TestSSDPProxy:
  512. """Tests for SSDPProxy (cross-network SSDP relay)."""
  513. @pytest.fixture
  514. def ssdp_proxy(self):
  515. """Create an SSDPProxy instance."""
  516. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
  517. return SSDPProxy(
  518. local_interface_ip="192.168.1.100",
  519. remote_interface_ip="10.0.0.100",
  520. target_printer_ip="192.168.1.50",
  521. )
  522. def test_ssdp_proxy_stores_interface_ips(self, ssdp_proxy):
  523. """Verify SSDPProxy stores interface IPs correctly."""
  524. assert ssdp_proxy.local_interface_ip == "192.168.1.100"
  525. assert ssdp_proxy.remote_interface_ip == "10.0.0.100"
  526. assert ssdp_proxy.target_printer_ip == "192.168.1.50"
  527. def test_rewrite_ssdp_location(self, ssdp_proxy):
  528. """Verify SSDP Location header is rewritten to remote interface IP."""
  529. original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
  530. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  531. # Location should be changed to remote interface IP
  532. assert b"Location: 10.0.0.100" in rewritten
  533. assert b"Location: 192.168.1.50" not in rewritten
  534. # Other headers should be preserved
  535. assert b"DevName.bambu.com: TestPrinter" in rewritten
  536. def test_rewrite_ssdp_location_case_insensitive(self, ssdp_proxy):
  537. """Verify SSDP Location rewrite is case insensitive."""
  538. original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
  539. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  540. assert b"10.0.0.100" in rewritten
  541. def test_rewrite_ssdp_location_no_match(self, ssdp_proxy):
  542. """Verify packet without Location header is returned unchanged."""
  543. original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
  544. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  545. # No Location header, but _rewrite_ssdp logs a warning and returns as-is
  546. assert b"DevName.bambu.com: Test" in rewritten
  547. def test_parse_ssdp_message(self, ssdp_proxy):
  548. """Verify SSDP message parsing extracts headers."""
  549. packet = (
  550. b"NOTIFY * HTTP/1.1\r\n"
  551. b"Location: 192.168.1.50\r\n"
  552. b"DevName.bambu.com: TestPrinter\r\n"
  553. b"DevModel.bambu.com: BL-P001\r\n"
  554. b"\r\n"
  555. )
  556. headers = ssdp_proxy._parse_ssdp_message(packet)
  557. assert headers["location"] == "192.168.1.50"
  558. assert headers["devname.bambu.com"] == "TestPrinter"
  559. assert headers["devmodel.bambu.com"] == "BL-P001"
  560. class TestVirtualPrinterManagerDirectories:
  561. """Tests for VirtualPrinterManager directory management."""
  562. def test_ensure_directories_creates_subdirs(self, tmp_path):
  563. """Verify _ensure_directories creates all required subdirectories."""
  564. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  565. # Create a manager and manually call _ensure_directories with our tmp path
  566. manager = VirtualPrinterManager()
  567. # Override the paths
  568. manager._base_dir = tmp_path / "virtual_printer"
  569. manager._upload_dir = manager._base_dir / "uploads"
  570. manager._cert_dir = manager._base_dir / "certs"
  571. # Call the method
  572. manager._ensure_directories()
  573. # All directories should be created
  574. assert (tmp_path / "virtual_printer").exists()
  575. assert (tmp_path / "virtual_printer" / "uploads").exists()
  576. assert (tmp_path / "virtual_printer" / "uploads" / "cache").exists()
  577. assert (tmp_path / "virtual_printer" / "certs").exists()
  578. def test_ensure_directories_handles_permission_error(self, tmp_path, caplog):
  579. """Verify _ensure_directories logs error on permission failure."""
  580. import logging
  581. from unittest.mock import patch
  582. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  583. # Create manager and override paths
  584. manager = VirtualPrinterManager()
  585. vp_dir = tmp_path / "virtual_printer"
  586. manager._base_dir = vp_dir
  587. manager._upload_dir = vp_dir / "uploads"
  588. manager._cert_dir = vp_dir / "certs"
  589. # Mock mkdir to raise PermissionError (chmod doesn't work as root in Docker)
  590. original_mkdir = type(vp_dir).mkdir
  591. def mock_mkdir(self, *args, **kwargs):
  592. if "virtual_printer" in str(self):
  593. raise PermissionError("Permission denied")
  594. return original_mkdir(self, *args, **kwargs)
  595. with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), "mkdir", mock_mkdir):
  596. # This should log errors but not raise
  597. manager._ensure_directories()
  598. # Check that error was logged
  599. assert "Permission denied" in caplog.text
  600. class TestVirtualPrinterManagerProxyMode:
  601. """Tests for VirtualPrinterManager proxy mode."""
  602. @pytest.fixture
  603. def manager(self):
  604. """Create a VirtualPrinterManager instance."""
  605. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  606. return VirtualPrinterManager()
  607. @pytest.mark.asyncio
  608. async def test_configure_proxy_mode_requires_target_ip(self, manager):
  609. """Verify proxy mode requires target_printer_ip."""
  610. with pytest.raises(ValueError, match="Target printer IP is required"):
  611. await manager.configure(
  612. enabled=True,
  613. mode="proxy",
  614. target_printer_ip="", # Empty target IP
  615. )
  616. @pytest.mark.asyncio
  617. async def test_configure_proxy_mode_does_not_require_access_code(self, manager):
  618. """Verify proxy mode does not require access code (uses real printer's)."""
  619. manager._start = AsyncMock()
  620. # Should not raise - proxy mode doesn't need access code
  621. await manager.configure(
  622. enabled=True,
  623. mode="proxy",
  624. target_printer_ip="192.168.1.100",
  625. )
  626. assert manager._mode == "proxy"
  627. assert manager._target_printer_ip == "192.168.1.100"
  628. def test_get_status_proxy_mode_includes_proxy_fields(self, manager):
  629. """Verify get_status includes proxy-specific fields in proxy mode."""
  630. manager._enabled = True
  631. manager._mode = "proxy"
  632. manager._target_printer_ip = "192.168.1.100"
  633. manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
  634. # Create a mock proxy with get_status
  635. mock_proxy = MagicMock()
  636. mock_proxy.get_status.return_value = {
  637. "running": True,
  638. "ftp_port": 990, # Privileged port for Bambu Studio compatibility
  639. "mqtt_port": 8883,
  640. "ftp_connections": 1,
  641. "mqtt_connections": 2,
  642. "target_host": "192.168.1.100",
  643. }
  644. manager._proxy = mock_proxy
  645. status = manager.get_status()
  646. assert status["mode"] == "proxy"
  647. assert status["target_printer_ip"] == "192.168.1.100"
  648. assert "proxy" in status
  649. assert status["proxy"]["ftp_port"] == 990 # Privileged port for Bambu Studio compatibility
  650. assert status["proxy"]["mqtt_port"] == 8883
  651. assert status["proxy"]["ftp_connections"] == 1
  652. assert status["proxy"]["mqtt_connections"] == 2
  653. @pytest.mark.asyncio
  654. async def test_configure_proxy_mode_with_remote_interface(self, manager):
  655. """Verify proxy mode accepts remote_interface_ip for SSDP proxy."""
  656. manager._start = AsyncMock()
  657. await manager.configure(
  658. enabled=True,
  659. mode="proxy",
  660. target_printer_ip="192.168.1.100",
  661. remote_interface_ip="10.0.0.50",
  662. )
  663. assert manager._mode == "proxy"
  664. assert manager._target_printer_ip == "192.168.1.100"
  665. assert manager._remote_interface_ip == "10.0.0.50"
  666. @pytest.mark.asyncio
  667. async def test_configure_proxy_mode_restarts_on_remote_interface_change(self, manager):
  668. """Verify changing remote_interface_ip restarts services in proxy mode."""
  669. # Simulate running state
  670. manager._enabled = True
  671. manager._mode = "proxy"
  672. manager._target_printer_ip = "192.168.1.100"
  673. manager._remote_interface_ip = "10.0.0.50"
  674. manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
  675. manager._stop = AsyncMock()
  676. manager._start = AsyncMock()
  677. await manager.configure(
  678. enabled=True,
  679. mode="proxy",
  680. target_printer_ip="192.168.1.100",
  681. remote_interface_ip="10.0.0.99", # Changed
  682. )
  683. # Should have stopped and started
  684. manager._stop.assert_called_once()
  685. manager._start.assert_called_once()
  686. class TestVirtualPrinterManagerServerModeIPOverride:
  687. """Tests for remote_interface_ip in server mode (immediate/review/print_queue)."""
  688. @pytest.fixture
  689. def manager(self):
  690. """Create a VirtualPrinterManager instance."""
  691. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  692. return VirtualPrinterManager()
  693. @pytest.mark.asyncio
  694. async def test_configure_immediate_mode_stores_remote_interface_ip(self, manager):
  695. """Verify immediate mode stores remote_interface_ip."""
  696. manager._start = AsyncMock()
  697. await manager.configure(
  698. enabled=True,
  699. access_code="12345678",
  700. mode="immediate",
  701. remote_interface_ip="10.0.0.50",
  702. )
  703. assert manager._remote_interface_ip == "10.0.0.50"
  704. @pytest.mark.asyncio
  705. async def test_configure_review_mode_stores_remote_interface_ip(self, manager):
  706. """Verify review mode stores remote_interface_ip."""
  707. manager._start = AsyncMock()
  708. await manager.configure(
  709. enabled=True,
  710. access_code="12345678",
  711. mode="review",
  712. remote_interface_ip="10.0.0.50",
  713. )
  714. assert manager._remote_interface_ip == "10.0.0.50"
  715. @pytest.mark.asyncio
  716. async def test_configure_print_queue_mode_stores_remote_interface_ip(self, manager):
  717. """Verify print_queue mode stores remote_interface_ip."""
  718. manager._start = AsyncMock()
  719. await manager.configure(
  720. enabled=True,
  721. access_code="12345678",
  722. mode="print_queue",
  723. remote_interface_ip="10.0.0.50",
  724. )
  725. assert manager._remote_interface_ip == "10.0.0.50"
  726. @pytest.mark.asyncio
  727. async def test_remote_interface_change_restarts_immediate_mode(self, manager):
  728. """Verify changing remote_interface_ip restarts services in immediate mode."""
  729. manager._enabled = True
  730. manager._mode = "immediate"
  731. manager._access_code = "12345678"
  732. manager._remote_interface_ip = "10.0.0.50"
  733. manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
  734. manager._stop = AsyncMock()
  735. manager._start = AsyncMock()
  736. await manager.configure(
  737. enabled=True,
  738. access_code="12345678",
  739. mode="immediate",
  740. remote_interface_ip="10.0.0.99", # Changed
  741. )
  742. manager._stop.assert_called_once()
  743. manager._start.assert_called_once()
  744. @pytest.mark.asyncio
  745. async def test_remote_interface_change_restarts_review_mode(self, manager):
  746. """Verify changing remote_interface_ip restarts services in review mode."""
  747. manager._enabled = True
  748. manager._mode = "review"
  749. manager._access_code = "12345678"
  750. manager._remote_interface_ip = "10.0.0.50"
  751. manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
  752. manager._stop = AsyncMock()
  753. manager._start = AsyncMock()
  754. await manager.configure(
  755. enabled=True,
  756. access_code="12345678",
  757. mode="review",
  758. remote_interface_ip="10.0.0.99", # Changed
  759. )
  760. manager._stop.assert_called_once()
  761. manager._start.assert_called_once()
  762. @pytest.mark.asyncio
  763. async def test_remote_interface_change_restarts_print_queue_mode(self, manager):
  764. """Verify changing remote_interface_ip restarts services in print_queue mode."""
  765. manager._enabled = True
  766. manager._mode = "print_queue"
  767. manager._access_code = "12345678"
  768. manager._remote_interface_ip = "10.0.0.50"
  769. manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
  770. manager._stop = AsyncMock()
  771. manager._start = AsyncMock()
  772. await manager.configure(
  773. enabled=True,
  774. access_code="12345678",
  775. mode="print_queue",
  776. remote_interface_ip="10.0.0.99", # Changed
  777. )
  778. manager._stop.assert_called_once()
  779. manager._start.assert_called_once()
  780. @pytest.mark.asyncio
  781. async def test_no_restart_when_remote_interface_unchanged(self, manager):
  782. """Verify no restart if remote_interface_ip hasn't changed."""
  783. manager._enabled = True
  784. manager._mode = "immediate"
  785. manager._access_code = "12345678"
  786. manager._remote_interface_ip = "10.0.0.50"
  787. manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
  788. manager._stop = AsyncMock()
  789. manager._start = AsyncMock()
  790. await manager.configure(
  791. enabled=True,
  792. access_code="12345678",
  793. mode="immediate",
  794. remote_interface_ip="10.0.0.50", # Same
  795. )
  796. manager._stop.assert_not_called()
  797. manager._start.assert_not_called()
  798. @pytest.mark.asyncio
  799. async def test_server_mode_passes_advertise_ip_to_ssdp(self, manager):
  800. """Verify _start_server_mode passes remote_interface_ip as advertise_ip to SSDP."""
  801. manager._mode = "immediate"
  802. manager._access_code = "12345678"
  803. manager._remote_interface_ip = "10.0.0.50"
  804. manager._model = "3DPrinter-X1-Carbon"
  805. with (
  806. patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer") as mock_ssdp_cls,
  807. patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
  808. patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
  809. patch("backend.app.services.virtual_printer.manager.BindServer"),
  810. patch.object(manager._cert_service, "delete_printer_certificate"),
  811. patch.object(
  812. manager._cert_service,
  813. "generate_certificates",
  814. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  815. ),
  816. ):
  817. mock_ssdp_cls.return_value.start = AsyncMock()
  818. await manager._start_server_mode()
  819. mock_ssdp_cls.assert_called_once_with(
  820. name="Bambuddy",
  821. serial=manager.printer_serial,
  822. model="3DPrinter-X1-Carbon",
  823. advertise_ip="10.0.0.50",
  824. )
  825. @pytest.mark.asyncio
  826. async def test_server_mode_passes_additional_ips_to_certificate(self, manager):
  827. """Verify _start_server_mode includes remote_interface_ip in certificate SANs."""
  828. manager._mode = "immediate"
  829. manager._access_code = "12345678"
  830. manager._remote_interface_ip = "10.0.0.50"
  831. manager._model = "3DPrinter-X1-Carbon"
  832. with (
  833. patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
  834. patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
  835. patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
  836. patch("backend.app.services.virtual_printer.manager.BindServer"),
  837. patch.object(manager._cert_service, "delete_printer_certificate"),
  838. patch.object(
  839. manager._cert_service,
  840. "generate_certificates",
  841. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  842. ) as mock_gen_certs,
  843. ):
  844. await manager._start_server_mode()
  845. mock_gen_certs.assert_called_once_with(additional_ips=["10.0.0.50"])
  846. @pytest.mark.asyncio
  847. async def test_server_mode_no_additional_ips_without_remote_interface(self, manager):
  848. """Verify _start_server_mode passes None for additional_ips when no remote interface."""
  849. manager._mode = "immediate"
  850. manager._access_code = "12345678"
  851. manager._remote_interface_ip = ""
  852. manager._model = "3DPrinter-X1-Carbon"
  853. with (
  854. patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
  855. patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
  856. patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
  857. patch("backend.app.services.virtual_printer.manager.BindServer"),
  858. patch.object(manager._cert_service, "delete_printer_certificate"),
  859. patch.object(
  860. manager._cert_service,
  861. "generate_certificates",
  862. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  863. ) as mock_gen_certs,
  864. ):
  865. await manager._start_server_mode()
  866. mock_gen_certs.assert_called_once_with(additional_ips=None)
  867. class TestBindServer:
  868. """Tests for the BindServer (port 3000 bind/detect protocol)."""
  869. @pytest.fixture
  870. def bind_server(self):
  871. """Create a BindServer instance."""
  872. from backend.app.services.virtual_printer.bind_server import BindServer
  873. return BindServer(
  874. serial="01S00C000000001",
  875. model="3DPrinter-X1-Carbon",
  876. name="Bambuddy",
  877. )
  878. def test_build_frame(self, bind_server):
  879. """Verify frame format: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
  880. payload = {"login": {"command": "detect"}}
  881. frame = bind_server._build_frame(payload)
  882. assert frame[:2] == b"\xa5\xa5"
  883. assert frame[-2:] == b"\xa7\xa7"
  884. # Length field is total message size
  885. import struct
  886. total_len = struct.unpack_from("<H", frame, 2)[0]
  887. assert total_len == len(frame)
  888. # JSON payload is between header and trailer
  889. import json
  890. json_bytes = frame[4:-2]
  891. parsed = json.loads(json_bytes)
  892. assert parsed == payload
  893. def test_parse_frame_valid(self, bind_server):
  894. """Verify valid frame parsing."""
  895. frame = bind_server._build_frame({"login": {"command": "detect", "sequence_id": "20000"}})
  896. result = bind_server._parse_frame(frame)
  897. assert result is not None
  898. assert result["login"]["command"] == "detect"
  899. assert result["login"]["sequence_id"] == "20000"
  900. def test_parse_frame_invalid_header(self, bind_server):
  901. """Verify invalid header returns None."""
  902. frame = b"\xb5\xb5\x10\x00" + b'{"login":{}}' + b"\xa7\xa7"
  903. assert bind_server._parse_frame(frame) is None
  904. def test_parse_frame_invalid_trailer(self, bind_server):
  905. """Verify invalid trailer returns None."""
  906. frame = b"\xa5\xa5\x10\x00" + b'{"login":{}}' + b"\xb7\xb7"
  907. assert bind_server._parse_frame(frame) is None
  908. def test_parse_frame_too_short(self, bind_server):
  909. """Verify short data returns None."""
  910. assert bind_server._parse_frame(b"\xa5\xa5\x00") is None
  911. assert bind_server._parse_frame(b"") is None
  912. def test_parse_frame_invalid_json(self, bind_server):
  913. """Verify invalid JSON returns None."""
  914. import struct
  915. bad_json = b"not json"
  916. total_len = 4 + len(bad_json) + 2
  917. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
  918. assert bind_server._parse_frame(frame) is None
  919. def test_build_frame_roundtrip(self, bind_server):
  920. """Verify build then parse roundtrip."""
  921. original = {"login": {"bind": "free", "command": "detect", "id": "01S00C000000001"}}
  922. frame = bind_server._build_frame(original)
  923. parsed = bind_server._parse_frame(frame)
  924. assert parsed == original
  925. def test_bind_server_stores_config(self, bind_server):
  926. """Verify config is stored correctly."""
  927. assert bind_server.serial == "01S00C000000001"
  928. assert bind_server.model == "3DPrinter-X1-Carbon"
  929. assert bind_server.name == "Bambuddy"
  930. assert bind_server.version == "01.00.00.00"
  931. def test_bind_server_custom_version(self):
  932. """Verify custom firmware version is stored."""
  933. from backend.app.services.virtual_printer.bind_server import BindServer
  934. server = BindServer(
  935. serial="01S00C000000001",
  936. model="3DPrinter-X1-Carbon",
  937. name="Bambuddy",
  938. version="01.09.00.10",
  939. )
  940. assert server.version == "01.09.00.10"
  941. @pytest.mark.asyncio
  942. async def test_server_mode_creates_bind_server(self):
  943. """Verify _start_server_mode creates BindServer with correct params."""
  944. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  945. manager = VirtualPrinterManager()
  946. manager._mode = "immediate"
  947. manager._access_code = "12345678"
  948. manager._remote_interface_ip = ""
  949. manager._model = "3DPrinter-X1-Carbon"
  950. with (
  951. patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
  952. patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
  953. patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
  954. patch("backend.app.services.virtual_printer.manager.BindServer") as mock_bind_cls,
  955. patch.object(manager._cert_service, "delete_printer_certificate"),
  956. patch.object(
  957. manager._cert_service,
  958. "generate_certificates",
  959. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  960. ),
  961. ):
  962. await manager._start_server_mode()
  963. mock_bind_cls.assert_called_once_with(
  964. serial=manager.printer_serial,
  965. model="3DPrinter-X1-Carbon",
  966. name="Bambuddy",
  967. )