test_virtual_printer.py 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410
  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 TestVirtualPrinterInstance:
  9. """Tests for VirtualPrinterInstance class."""
  10. @pytest.fixture
  11. def instance(self, tmp_path):
  12. """Create a VirtualPrinterInstance with test defaults."""
  13. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  14. return VirtualPrinterInstance(
  15. vp_id=1,
  16. name="TestPrinter",
  17. mode="immediate",
  18. model="C11",
  19. access_code="12345678",
  20. serial_suffix="391800001",
  21. base_dir=tmp_path,
  22. )
  23. # ========================================================================
  24. # Tests for instance properties
  25. # ========================================================================
  26. def test_instance_stores_parameters(self, instance):
  27. """Verify constructor stores parameters correctly."""
  28. assert instance.id == 1
  29. assert instance.name == "TestPrinter"
  30. assert instance.mode == "immediate"
  31. assert instance.model == "C11"
  32. assert instance.access_code == "12345678"
  33. assert instance.serial_suffix == "391800001"
  34. def test_instance_serial_property(self, instance):
  35. """Verify serial is generated from model prefix + suffix."""
  36. # C11 = P1P, prefix = 01S00A
  37. assert instance.serial == "01S00A391800001"
  38. def test_instance_serial_x1c(self, tmp_path):
  39. """Verify X1C serial uses correct prefix."""
  40. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  41. inst = VirtualPrinterInstance(
  42. vp_id=2,
  43. name="X1C",
  44. mode="immediate",
  45. model="3DPrinter-X1-Carbon",
  46. access_code="12345678",
  47. serial_suffix="391800002",
  48. base_dir=tmp_path,
  49. )
  50. assert inst.serial == "00M00A391800002"
  51. def test_instance_is_proxy_false(self, instance):
  52. """Verify is_proxy is False for non-proxy mode."""
  53. assert instance.is_proxy is False
  54. def test_instance_is_proxy_true(self, tmp_path):
  55. """Verify is_proxy is True for proxy mode."""
  56. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  57. inst = VirtualPrinterInstance(
  58. vp_id=3,
  59. name="Proxy",
  60. mode="proxy",
  61. model="C11",
  62. access_code="",
  63. serial_suffix="391800003",
  64. target_printer_ip="192.168.1.100",
  65. base_dir=tmp_path,
  66. )
  67. assert inst.is_proxy is True
  68. def test_instance_is_running_with_active_tasks(self, instance):
  69. """Verify is_running is True when tasks are active."""
  70. mock_task = MagicMock()
  71. mock_task.done.return_value = False
  72. instance._tasks = [mock_task]
  73. assert instance.is_running is True
  74. def test_instance_is_running_with_no_tasks(self, instance):
  75. """Verify is_running is False when no tasks."""
  76. assert instance.is_running is False
  77. def test_instance_creates_directories(self, instance, tmp_path):
  78. """Verify instance creates upload and cert directories."""
  79. assert (tmp_path / "uploads" / "1").exists()
  80. assert (tmp_path / "uploads" / "1" / "cache").exists()
  81. assert (tmp_path / "certs" / "1").exists()
  82. # ========================================================================
  83. # Tests for status
  84. # ========================================================================
  85. def test_get_status_returns_correct_format(self, instance):
  86. """Verify get_status returns expected fields."""
  87. instance._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")} # nosec B108
  88. mock_task = MagicMock(done=MagicMock(return_value=False))
  89. instance._tasks = [mock_task]
  90. status = instance.get_status()
  91. assert status["running"] is True
  92. assert status["pending_files"] == 1
  93. def test_get_status_not_running(self, instance):
  94. """Verify get_status when no tasks."""
  95. status = instance.get_status()
  96. assert status["running"] is False
  97. assert status["pending_files"] == 0
  98. # ========================================================================
  99. # Tests for file handling
  100. # ========================================================================
  101. @pytest.mark.asyncio
  102. async def test_on_file_received_adds_to_pending(self, instance):
  103. """Verify received file is added to pending list in review mode."""
  104. instance.mode = "review"
  105. file_path = Path("/tmp/test.3mf") # nosec B108
  106. with patch.object(instance, "_queue_file", new_callable=AsyncMock) as mock_queue:
  107. await instance.on_file_received(file_path, "192.168.1.100")
  108. assert "test.3mf" in instance._pending_files
  109. mock_queue.assert_called_once()
  110. @pytest.mark.asyncio
  111. async def test_on_file_received_archives_immediately(self, instance):
  112. """Verify file is archived in immediate mode."""
  113. file_path = Path("/tmp/test.3mf") # nosec B108
  114. with patch.object(instance, "_archive_file", new_callable=AsyncMock) as mock_archive:
  115. await instance.on_file_received(file_path, "192.168.1.100")
  116. mock_archive.assert_called_once_with(file_path, "192.168.1.100")
  117. @pytest.mark.asyncio
  118. async def test_archive_file_skips_non_3mf(self, instance):
  119. """Verify non-3MF files are skipped and cleaned up."""
  120. instance._session_factory = MagicMock()
  121. instance._pending_files["verify_job"] = Path("/tmp/verify_job") # nosec B108
  122. with patch("pathlib.Path.unlink"):
  123. await instance._archive_file(Path("/tmp/verify_job"), "192.168.1.100") # nosec B108
  124. assert "verify_job" not in instance._pending_files
  125. class TestVirtualPrinterManager:
  126. """Tests for VirtualPrinterManager orchestrator."""
  127. @pytest.fixture
  128. def manager(self):
  129. """Create a VirtualPrinterManager instance."""
  130. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  131. return VirtualPrinterManager()
  132. def test_manager_starts_empty(self, manager):
  133. """Verify manager starts with no instances."""
  134. assert len(manager._instances) == 0
  135. assert manager.is_enabled is False
  136. def test_manager_get_status_empty(self, manager):
  137. """Verify get_status returns disabled state when no instances."""
  138. status = manager.get_status()
  139. assert status["enabled"] is False
  140. assert status["running"] is False
  141. assert status["mode"] == "immediate"
  142. def test_manager_is_enabled_with_instance(self, manager, tmp_path):
  143. """Verify is_enabled is True when instances exist."""
  144. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  145. inst = VirtualPrinterInstance(
  146. vp_id=1,
  147. name="Test",
  148. mode="immediate",
  149. model="C11",
  150. access_code="12345678",
  151. serial_suffix="391800001",
  152. base_dir=tmp_path,
  153. )
  154. manager._instances[1] = inst
  155. assert manager.is_enabled is True
  156. @pytest.mark.asyncio
  157. async def test_manager_remove_instance_server(self, manager, tmp_path):
  158. """Verify remove_instance stops and removes a server-mode instance."""
  159. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  160. inst = VirtualPrinterInstance(
  161. vp_id=1,
  162. name="Test",
  163. mode="immediate",
  164. model="C11",
  165. access_code="12345678",
  166. serial_suffix="391800001",
  167. base_dir=tmp_path,
  168. )
  169. inst.stop_server = AsyncMock()
  170. manager._instances[1] = inst
  171. await manager.remove_instance(1)
  172. assert 1 not in manager._instances
  173. inst.stop_server.assert_called_once()
  174. @pytest.mark.asyncio
  175. async def test_manager_remove_instance_proxy(self, manager, tmp_path):
  176. """Verify remove_instance stops proxy-mode instance."""
  177. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  178. inst = VirtualPrinterInstance(
  179. vp_id=2,
  180. name="Proxy",
  181. mode="proxy",
  182. model="C11",
  183. access_code="",
  184. serial_suffix="391800002",
  185. target_printer_ip="192.168.1.100",
  186. base_dir=tmp_path,
  187. )
  188. inst.stop_proxy = AsyncMock()
  189. manager._instances[2] = inst
  190. await manager.remove_instance(2)
  191. assert 2 not in manager._instances
  192. inst.stop_proxy.assert_called_once()
  193. def test_manager_get_status_with_instance(self, manager, tmp_path):
  194. """Verify legacy get_status returns first instance data."""
  195. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  196. inst = VirtualPrinterInstance(
  197. vp_id=1,
  198. name="Bambuddy",
  199. mode="immediate",
  200. model="C11",
  201. access_code="12345678",
  202. serial_suffix="391800001",
  203. base_dir=tmp_path,
  204. )
  205. mock_task = MagicMock(done=MagicMock(return_value=False))
  206. inst._tasks = [mock_task]
  207. inst._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")} # nosec B108
  208. manager._instances[1] = inst
  209. status = manager.get_status()
  210. assert status["enabled"] is True
  211. assert status["running"] is True
  212. assert status["mode"] == "immediate"
  213. assert status["name"] == "Bambuddy"
  214. assert status["serial"] == "01S00A391800001"
  215. assert status["model"] == "C11"
  216. assert status["model_name"] == "P1P"
  217. assert status["pending_files"] == 1
  218. def test_manager_get_all_status(self, manager, tmp_path):
  219. """Verify get_all_status returns status for all instances."""
  220. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  221. for i in range(1, 3):
  222. inst = VirtualPrinterInstance(
  223. vp_id=i,
  224. name=f"VP{i}",
  225. mode="immediate",
  226. model="C11",
  227. access_code="12345678",
  228. serial_suffix=f"39180000{i}",
  229. base_dir=tmp_path,
  230. )
  231. manager._instances[i] = inst
  232. statuses = manager.get_all_status()
  233. assert len(statuses) == 2
  234. assert statuses[0]["name"] == "VP1"
  235. assert statuses[1]["name"] == "VP2"
  236. @pytest.mark.asyncio
  237. async def test_manager_stop_all(self, manager, tmp_path):
  238. """Verify stop_all removes all instances."""
  239. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  240. for i in range(1, 3):
  241. inst = VirtualPrinterInstance(
  242. vp_id=i,
  243. name=f"VP{i}",
  244. mode="immediate",
  245. model="C11",
  246. access_code="12345678",
  247. serial_suffix=f"39180000{i}",
  248. base_dir=tmp_path,
  249. )
  250. inst.stop_server = AsyncMock()
  251. manager._instances[i] = inst
  252. await manager.stop_all()
  253. assert len(manager._instances) == 0
  254. # ========================================================================
  255. # Tests for sync_from_db config change detection
  256. # ========================================================================
  257. def _make_db_vp(self, **overrides):
  258. """Create a mock VirtualPrinter DB object."""
  259. defaults = {
  260. "id": 1,
  261. "name": "TestVP",
  262. "enabled": True,
  263. "mode": "immediate",
  264. "model": "C11",
  265. "access_code": "12345678",
  266. "serial_suffix": "391800001",
  267. "bind_ip": "",
  268. "remote_interface_ip": "",
  269. "target_printer_id": None,
  270. "position": 0,
  271. }
  272. defaults.update(overrides)
  273. vp = MagicMock()
  274. for k, v in defaults.items():
  275. setattr(vp, k, v)
  276. return vp
  277. def _setup_sync_mocks(self, manager, enabled_vps, tmp_path):
  278. """Wire up session_factory mock for sync_from_db."""
  279. mock_result = MagicMock()
  280. mock_result.scalars.return_value.all.return_value = enabled_vps
  281. mock_db = AsyncMock()
  282. mock_db.execute = AsyncMock(return_value=mock_result)
  283. mock_db.__aenter__ = AsyncMock(return_value=mock_db)
  284. mock_db.__aexit__ = AsyncMock(return_value=False)
  285. manager._session_factory = MagicMock(return_value=mock_db)
  286. manager._base_dir = tmp_path
  287. @pytest.mark.asyncio
  288. async def test_sync_from_db_restarts_on_mode_change(self, manager, tmp_path):
  289. """Verify sync_from_db restarts VP when mode changes."""
  290. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  291. inst = VirtualPrinterInstance(
  292. vp_id=1,
  293. name="TestVP",
  294. mode="immediate",
  295. model="C11",
  296. access_code="12345678",
  297. serial_suffix="391800001",
  298. base_dir=tmp_path,
  299. )
  300. inst.stop_server = AsyncMock()
  301. manager._instances[1] = inst
  302. # DB says mode changed to "archive"
  303. db_vp = self._make_db_vp(mode="archive")
  304. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  305. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  306. # Patch VirtualPrinterInstance to prevent actual start
  307. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  308. mock_new = MagicMock()
  309. mock_new.start_server = AsyncMock()
  310. MockInst.return_value = mock_new
  311. await manager.sync_from_db()
  312. mock_remove.assert_called_once_with(1)
  313. @pytest.mark.asyncio
  314. async def test_sync_from_db_restarts_on_access_code_change(self, manager, tmp_path):
  315. """Verify sync_from_db restarts VP when access_code changes."""
  316. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  317. inst = VirtualPrinterInstance(
  318. vp_id=1,
  319. name="TestVP",
  320. mode="immediate",
  321. model="C11",
  322. access_code="12345678",
  323. serial_suffix="391800001",
  324. base_dir=tmp_path,
  325. )
  326. inst.stop_server = AsyncMock()
  327. manager._instances[1] = inst
  328. db_vp = self._make_db_vp(access_code="newcode99")
  329. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  330. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  331. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  332. mock_new = MagicMock()
  333. mock_new.start_server = AsyncMock()
  334. MockInst.return_value = mock_new
  335. await manager.sync_from_db()
  336. mock_remove.assert_called_once_with(1)
  337. @pytest.mark.asyncio
  338. async def test_sync_from_db_skips_unchanged_instance(self, manager, tmp_path):
  339. """Verify sync_from_db does NOT restart when config is identical."""
  340. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  341. inst = VirtualPrinterInstance(
  342. vp_id=1,
  343. name="TestVP",
  344. mode="immediate",
  345. model="C11",
  346. access_code="12345678",
  347. serial_suffix="391800001",
  348. base_dir=tmp_path,
  349. )
  350. manager._instances[1] = inst
  351. # DB matches running config exactly
  352. db_vp = self._make_db_vp()
  353. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  354. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  355. await manager.sync_from_db()
  356. mock_remove.assert_not_called()
  357. @pytest.mark.asyncio
  358. async def test_sync_from_db_restarts_on_bind_ip_change(self, manager, tmp_path):
  359. """Verify sync_from_db restarts VP when bind_ip changes."""
  360. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  361. inst = VirtualPrinterInstance(
  362. vp_id=1,
  363. name="TestVP",
  364. mode="immediate",
  365. model="C11",
  366. access_code="12345678",
  367. serial_suffix="391800001",
  368. bind_ip="192.168.1.10",
  369. base_dir=tmp_path,
  370. )
  371. inst.stop_server = AsyncMock()
  372. manager._instances[1] = inst
  373. db_vp = self._make_db_vp(bind_ip="192.168.1.20")
  374. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  375. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  376. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  377. mock_new = MagicMock()
  378. mock_new.start_server = AsyncMock()
  379. MockInst.return_value = mock_new
  380. await manager.sync_from_db()
  381. mock_remove.assert_called_once_with(1)
  382. @pytest.mark.asyncio
  383. async def test_sync_from_db_restarts_on_model_change(self, manager, tmp_path):
  384. """Verify sync_from_db restarts VP when model changes."""
  385. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  386. inst = VirtualPrinterInstance(
  387. vp_id=1,
  388. name="TestVP",
  389. mode="immediate",
  390. model="C11",
  391. access_code="12345678",
  392. serial_suffix="391800001",
  393. base_dir=tmp_path,
  394. )
  395. inst.stop_server = AsyncMock()
  396. manager._instances[1] = inst
  397. db_vp = self._make_db_vp(model="C12")
  398. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  399. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  400. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  401. mock_new = MagicMock()
  402. mock_new.start_server = AsyncMock()
  403. MockInst.return_value = mock_new
  404. await manager.sync_from_db()
  405. mock_remove.assert_called_once_with(1)
  406. class TestFTPSession:
  407. """Tests for FTP session handling."""
  408. @pytest.fixture
  409. def mock_reader(self):
  410. """Create a mock StreamReader."""
  411. reader = AsyncMock()
  412. return reader
  413. @pytest.fixture
  414. def mock_writer(self):
  415. """Create a mock StreamWriter."""
  416. writer = MagicMock()
  417. writer.get_extra_info = MagicMock(return_value=("192.168.1.100", 12345))
  418. writer.write = MagicMock()
  419. writer.drain = AsyncMock()
  420. writer.close = MagicMock()
  421. writer.wait_closed = AsyncMock()
  422. writer.is_closing = MagicMock(return_value=False)
  423. return writer
  424. @pytest.fixture
  425. def ssl_context(self):
  426. """Create a mock SSL context."""
  427. return MagicMock()
  428. @pytest.fixture
  429. def session(self, mock_reader, mock_writer, ssl_context, tmp_path):
  430. """Create an FTPSession instance."""
  431. from backend.app.services.virtual_printer.ftp_server import FTPSession
  432. return FTPSession(
  433. reader=mock_reader,
  434. writer=mock_writer,
  435. upload_dir=tmp_path,
  436. access_code="12345678",
  437. ssl_context=ssl_context,
  438. on_file_received=None,
  439. )
  440. # ========================================================================
  441. # Tests for authentication
  442. # ========================================================================
  443. @pytest.mark.asyncio
  444. async def test_user_command_accepts_bblp(self, session):
  445. """Verify USER command accepts bblp user."""
  446. await session.cmd_USER("bblp")
  447. assert session.username == "bblp"
  448. @pytest.mark.asyncio
  449. async def test_pass_command_authenticates(self, session):
  450. """Verify PASS command authenticates with correct code."""
  451. session.username = "bblp"
  452. await session.cmd_PASS("12345678")
  453. assert session.authenticated is True
  454. @pytest.mark.asyncio
  455. async def test_pass_command_rejects_wrong_code(self, session):
  456. """Verify PASS command rejects wrong access code."""
  457. session.username = "bblp"
  458. await session.cmd_PASS("wrongcode")
  459. assert session.authenticated is False
  460. # ========================================================================
  461. # Tests for FTP commands
  462. # ========================================================================
  463. @pytest.mark.asyncio
  464. async def test_syst_command(self, session):
  465. """Verify SYST returns UNIX type."""
  466. await session.cmd_SYST("")
  467. session.writer.write.assert_called()
  468. call_args = session.writer.write.call_args[0][0].decode()
  469. assert "215" in call_args
  470. assert "UNIX" in call_args
  471. @pytest.mark.asyncio
  472. async def test_pwd_command_requires_auth(self, session):
  473. """Verify PWD requires authentication."""
  474. session.authenticated = False
  475. await session.cmd_PWD("")
  476. call_args = session.writer.write.call_args[0][0].decode()
  477. assert "530" in call_args
  478. @pytest.mark.asyncio
  479. async def test_pwd_command_when_authenticated(self, session):
  480. """Verify PWD returns root directory when authenticated."""
  481. session.authenticated = True
  482. await session.cmd_PWD("")
  483. call_args = session.writer.write.call_args[0][0].decode()
  484. assert "257" in call_args
  485. @pytest.mark.asyncio
  486. async def test_type_command_sets_binary(self, session):
  487. """Verify TYPE I sets binary mode."""
  488. session.authenticated = True
  489. await session.cmd_TYPE("I")
  490. assert session.transfer_type == "I"
  491. @pytest.mark.asyncio
  492. async def test_pbsz_command(self, session):
  493. """Verify PBSZ returns success."""
  494. await session.cmd_PBSZ("0")
  495. call_args = session.writer.write.call_args[0][0].decode()
  496. assert "200" in call_args
  497. @pytest.mark.asyncio
  498. async def test_prot_command_accepts_p(self, session):
  499. """Verify PROT P is accepted."""
  500. await session.cmd_PROT("P")
  501. call_args = session.writer.write.call_args[0][0].decode()
  502. assert "200" in call_args
  503. @pytest.mark.asyncio
  504. async def test_quit_command(self, session):
  505. """Verify QUIT sends goodbye and raises CancelledError."""
  506. with pytest.raises(asyncio.CancelledError):
  507. await session.cmd_QUIT("")
  508. class TestSSDPServer:
  509. """Tests for Virtual Printer SSDP server."""
  510. @pytest.fixture
  511. def ssdp_server(self):
  512. """Create a VirtualPrinterSSDPServer instance."""
  513. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  514. return VirtualPrinterSSDPServer(
  515. serial="TEST123",
  516. name="TestPrinter",
  517. model="BL-P001",
  518. )
  519. # ========================================================================
  520. # Tests for SSDP response
  521. # ========================================================================
  522. def test_build_notify_message(self, ssdp_server):
  523. """Verify NOTIFY packet contains required headers."""
  524. # Set a known IP for testing
  525. ssdp_server._local_ip = "192.168.1.100"
  526. message = ssdp_server._build_notify_message()
  527. assert b"NOTIFY" in message
  528. assert b"DevName.bambu.com: TestPrinter" in message
  529. assert b"USN: TEST123" in message
  530. def test_build_response_message(self, ssdp_server):
  531. """Verify response packet contains required headers."""
  532. # Set a known IP for testing
  533. ssdp_server._local_ip = "192.168.1.100"
  534. message = ssdp_server._build_response_message()
  535. assert b"HTTP/1.1 200 OK" in message
  536. assert b"DevName.bambu.com: TestPrinter" in message
  537. assert b"USN: TEST123" in message
  538. def test_ssdp_server_uses_correct_model(self, ssdp_server):
  539. """Verify SSDP server uses the provided model."""
  540. ssdp_server._local_ip = "192.168.1.100"
  541. message = ssdp_server._build_notify_message()
  542. assert b"DevModel.bambu.com: BL-P001" in message
  543. # ========================================================================
  544. # Tests for advertise_ip parameter
  545. # ========================================================================
  546. def test_advertise_ip_sets_local_ip(self):
  547. """Verify advertise_ip overrides auto-detection."""
  548. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  549. server = VirtualPrinterSSDPServer(
  550. serial="TEST123",
  551. name="TestPrinter",
  552. model="BL-P001",
  553. advertise_ip="10.0.0.50",
  554. )
  555. assert server._local_ip == "10.0.0.50"
  556. def test_advertise_ip_empty_string_uses_auto_detect(self):
  557. """Verify empty advertise_ip falls back to auto-detection."""
  558. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  559. server = VirtualPrinterSSDPServer(
  560. serial="TEST123",
  561. name="TestPrinter",
  562. model="BL-P001",
  563. advertise_ip="",
  564. )
  565. assert server._local_ip is None
  566. def test_advertise_ip_in_notify_message(self):
  567. """Verify NOTIFY message uses the advertise_ip."""
  568. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  569. server = VirtualPrinterSSDPServer(
  570. serial="TEST123",
  571. name="TestPrinter",
  572. model="BL-P001",
  573. advertise_ip="10.0.0.50",
  574. )
  575. message = server._build_notify_message()
  576. assert b"Location: 10.0.0.50" in message
  577. def test_advertise_ip_in_response_message(self):
  578. """Verify M-SEARCH response uses the advertise_ip."""
  579. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  580. server = VirtualPrinterSSDPServer(
  581. serial="TEST123",
  582. name="TestPrinter",
  583. model="BL-P001",
  584. advertise_ip="10.0.0.50",
  585. )
  586. message = server._build_response_message()
  587. assert b"Location: 10.0.0.50" in message
  588. def test_default_no_advertise_ip(self):
  589. """Verify default constructor has None local_ip (auto-detect)."""
  590. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  591. server = VirtualPrinterSSDPServer()
  592. assert server._local_ip is None
  593. class TestCertificateService:
  594. """Tests for TLS certificate generation."""
  595. @pytest.fixture
  596. def cert_service(self, tmp_path):
  597. """Create a CertificateService instance."""
  598. from backend.app.services.virtual_printer.certificate import CertificateService
  599. return CertificateService(cert_dir=tmp_path, serial="TEST123")
  600. def test_generate_certificates(self, cert_service, tmp_path):
  601. """Verify certificates are generated correctly."""
  602. cert_path, key_path = cert_service.generate_certificates()
  603. assert cert_path.exists()
  604. assert key_path.exists()
  605. # Verify certificate content
  606. cert_content = cert_path.read_text()
  607. assert "BEGIN CERTIFICATE" in cert_content
  608. key_content = key_path.read_text()
  609. assert "BEGIN" in key_content and "KEY" in key_content
  610. def test_certificates_reused_if_exist(self, cert_service):
  611. """Verify existing certificates are reused."""
  612. # First generation
  613. cert_path1, key_path1 = cert_service.generate_certificates()
  614. mtime1 = cert_path1.stat().st_mtime
  615. # Second call should reuse (via ensure_certificates)
  616. cert_path2, key_path2 = cert_service.ensure_certificates()
  617. mtime2 = cert_path2.stat().st_mtime
  618. assert mtime1 == mtime2 # File wasn't regenerated
  619. def test_delete_certificates(self, cert_service):
  620. """Verify certificates can be deleted."""
  621. cert_service.generate_certificates()
  622. assert cert_service.cert_path.exists()
  623. assert cert_service.key_path.exists()
  624. cert_service.delete_certificates()
  625. assert not cert_service.cert_path.exists()
  626. assert not cert_service.key_path.exists()
  627. def test_ensure_creates_if_not_exist(self, cert_service):
  628. """Verify ensure_certificates generates if not existing."""
  629. assert not cert_service.cert_path.exists()
  630. cert_path, key_path = cert_service.ensure_certificates()
  631. assert cert_path.exists()
  632. assert key_path.exists()
  633. class TestBindServer:
  634. """Tests for BindServer (port 3002 bind/detect protocol)."""
  635. @pytest.fixture
  636. def bind_server(self):
  637. """Create a BindServer instance."""
  638. from backend.app.services.virtual_printer.bind_server import BindServer
  639. return BindServer(
  640. serial="09400A391800001",
  641. model="O1D",
  642. name="Bambuddy",
  643. )
  644. def test_build_frame(self, bind_server):
  645. """Verify frame building produces correct format."""
  646. payload = {"login": {"command": "detect"}}
  647. frame = bind_server._build_frame(payload)
  648. # Header: 0xA5A5
  649. assert frame[:2] == b"\xa5\xa5"
  650. # Trailer: 0xA7A7
  651. assert frame[-2:] == b"\xa7\xa7"
  652. # Length field is total message size (LE uint16)
  653. import struct
  654. total_len = struct.unpack_from("<H", frame, 2)[0]
  655. assert total_len == len(frame)
  656. # JSON payload is between header and trailer
  657. import json
  658. json_bytes = frame[4:-2]
  659. parsed = json.loads(json_bytes)
  660. assert parsed == payload
  661. def test_parse_frame_valid(self, bind_server):
  662. """Verify valid frame parsing extracts JSON correctly."""
  663. import json
  664. import struct
  665. payload = {"login": {"command": "detect", "sequence_id": "20000"}}
  666. json_bytes = json.dumps(payload, separators=(",", ":")).encode()
  667. total_len = 4 + len(json_bytes) + 2
  668. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + json_bytes + b"\xa7\xa7"
  669. result = bind_server._parse_frame(frame)
  670. assert result is not None
  671. assert result["login"]["command"] == "detect"
  672. assert result["login"]["sequence_id"] == "20000"
  673. def test_parse_frame_invalid_header(self, bind_server):
  674. """Verify invalid header returns None."""
  675. result = bind_server._parse_frame(b"\xbb\xbb\x06\x00{}\xa7\xa7")
  676. assert result is None
  677. def test_parse_frame_invalid_trailer(self, bind_server):
  678. """Verify invalid trailer returns None."""
  679. result = bind_server._parse_frame(b"\xa5\xa5\x06\x00{}\xbb\xbb")
  680. assert result is None
  681. def test_parse_frame_too_short(self, bind_server):
  682. """Verify short data returns None."""
  683. result = bind_server._parse_frame(b"\xa5\xa5\x00")
  684. assert result is None
  685. def test_parse_frame_invalid_json(self, bind_server):
  686. """Verify invalid JSON returns None."""
  687. import struct
  688. bad_json = b"not json"
  689. total_len = 4 + len(bad_json) + 2
  690. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
  691. result = bind_server._parse_frame(frame)
  692. assert result is None
  693. def test_build_frame_roundtrip(self, bind_server):
  694. """Verify build_frame output can be parsed back."""
  695. payload = {
  696. "login": {
  697. "bind": "free",
  698. "command": "detect",
  699. "connect": "lan",
  700. "dev_cap": 1,
  701. "id": "09400A391800001",
  702. "model": "O1D",
  703. "name": "Bambuddy",
  704. "sequence_id": 3021,
  705. "version": "01.00.00.00",
  706. }
  707. }
  708. frame = bind_server._build_frame(payload)
  709. parsed = bind_server._parse_frame(frame)
  710. assert parsed is not None
  711. assert parsed["login"]["id"] == "09400A391800001"
  712. assert parsed["login"]["model"] == "O1D"
  713. assert parsed["login"]["name"] == "Bambuddy"
  714. assert parsed["login"]["bind"] == "free"
  715. def test_bind_server_stores_config(self, bind_server):
  716. """Verify bind server stores serial, model, name."""
  717. assert bind_server.serial == "09400A391800001"
  718. assert bind_server.model == "O1D"
  719. assert bind_server.name == "Bambuddy"
  720. assert bind_server.version == "01.00.00.00"
  721. def test_bind_server_custom_version(self):
  722. """Verify custom firmware version is stored."""
  723. from backend.app.services.virtual_printer.bind_server import BindServer
  724. server = BindServer(
  725. serial="TEST123",
  726. model="C13",
  727. name="Test",
  728. version="02.03.04.05",
  729. )
  730. assert server.version == "02.03.04.05"
  731. def test_bind_ports_constant(self):
  732. """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
  733. from backend.app.services.virtual_printer.bind_server import BIND_PORTS
  734. assert 3000 in BIND_PORTS
  735. assert 3002 in BIND_PORTS
  736. def test_bind_server_initializes_empty_servers_list(self, bind_server):
  737. """Verify bind server starts with empty servers list."""
  738. assert bind_server._servers == []
  739. assert bind_server._running is False
  740. class TestSlicerProxyManager:
  741. """Tests for SlicerProxyManager (proxy mode)."""
  742. @pytest.fixture
  743. def proxy_manager(self, tmp_path):
  744. """Create a SlicerProxyManager instance."""
  745. from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
  746. # Create dummy cert files
  747. cert_path = tmp_path / "cert.pem"
  748. key_path = tmp_path / "key.pem"
  749. cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
  750. # Split string to avoid pre-commit hook false positive on test data
  751. key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
  752. return SlicerProxyManager(
  753. target_host="192.168.1.100",
  754. cert_path=cert_path,
  755. key_path=key_path,
  756. )
  757. def test_proxy_manager_initializes_ports(self, proxy_manager):
  758. """Verify proxy manager has correct port constants."""
  759. # FTP proxy uses privileged port 990 to match what Bambu Studio expects
  760. assert proxy_manager.LOCAL_FTP_PORT == 990
  761. assert proxy_manager.LOCAL_MQTT_PORT == 8883
  762. assert proxy_manager.PRINTER_FTP_PORT == 990
  763. assert proxy_manager.PRINTER_MQTT_PORT == 8883
  764. # Bind ports: both 3000 and 3002 for slicer compatibility
  765. assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]
  766. def test_proxy_manager_stores_target_host(self, proxy_manager):
  767. """Verify proxy manager stores target host."""
  768. assert proxy_manager.target_host == "192.168.1.100"
  769. def test_get_status_before_start(self, proxy_manager):
  770. """Verify get_status returns zeros before start."""
  771. status = proxy_manager.get_status()
  772. assert status["running"] is False
  773. assert status["ftp_connections"] == 0
  774. assert status["mqtt_connections"] == 0
  775. class TestSSDPProxy:
  776. """Tests for SSDPProxy (cross-network SSDP relay)."""
  777. @pytest.fixture
  778. def ssdp_proxy(self):
  779. """Create an SSDPProxy instance."""
  780. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
  781. return SSDPProxy(
  782. local_interface_ip="192.168.1.100",
  783. remote_interface_ip="10.0.0.100",
  784. target_printer_ip="192.168.1.50",
  785. )
  786. def test_ssdp_proxy_stores_interface_ips(self, ssdp_proxy):
  787. """Verify SSDPProxy stores interface IPs correctly."""
  788. assert ssdp_proxy.local_interface_ip == "192.168.1.100"
  789. assert ssdp_proxy.remote_interface_ip == "10.0.0.100"
  790. assert ssdp_proxy.target_printer_ip == "192.168.1.50"
  791. def test_rewrite_ssdp_location(self, ssdp_proxy):
  792. """Verify SSDP Location header is rewritten to remote interface IP."""
  793. original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
  794. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  795. # Location should be changed to remote interface IP
  796. assert b"Location: 10.0.0.100" in rewritten
  797. assert b"Location: 192.168.1.50" not in rewritten
  798. # Other headers should be preserved
  799. assert b"DevName.bambu.com: TestPrinter" in rewritten
  800. def test_rewrite_ssdp_location_case_insensitive(self, ssdp_proxy):
  801. """Verify SSDP Location rewrite is case insensitive."""
  802. original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
  803. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  804. assert b"10.0.0.100" in rewritten
  805. def test_rewrite_ssdp_location_no_match(self, ssdp_proxy):
  806. """Verify packet without Location header is returned unchanged."""
  807. original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
  808. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  809. # No Location header, but _rewrite_ssdp logs a warning and returns as-is
  810. assert b"DevName.bambu.com: Test" in rewritten
  811. def test_parse_ssdp_message(self, ssdp_proxy):
  812. """Verify SSDP message parsing extracts headers."""
  813. packet = (
  814. b"NOTIFY * HTTP/1.1\r\n"
  815. b"Location: 192.168.1.50\r\n"
  816. b"DevName.bambu.com: TestPrinter\r\n"
  817. b"DevModel.bambu.com: BL-P001\r\n"
  818. b"\r\n"
  819. )
  820. headers = ssdp_proxy._parse_ssdp_message(packet)
  821. assert headers["location"] == "192.168.1.50"
  822. assert headers["devname.bambu.com"] == "TestPrinter"
  823. assert headers["devmodel.bambu.com"] == "BL-P001"
  824. class TestVirtualPrinterManagerDirectories:
  825. """Tests for VirtualPrinterManager directory management."""
  826. def test_ensure_base_directories_creates_subdirs(self, tmp_path):
  827. """Verify _ensure_base_directories creates required base directories."""
  828. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  829. manager = VirtualPrinterManager()
  830. manager._base_dir = tmp_path / "virtual_printer"
  831. manager._ensure_base_directories()
  832. assert (tmp_path / "virtual_printer").exists()
  833. assert (tmp_path / "virtual_printer" / "uploads").exists()
  834. assert (tmp_path / "virtual_printer" / "certs").exists()
  835. def test_ensure_base_directories_handles_permission_error(self, tmp_path, caplog):
  836. """Verify _ensure_base_directories logs error on permission failure."""
  837. import logging
  838. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  839. manager = VirtualPrinterManager()
  840. vp_dir = tmp_path / "virtual_printer"
  841. manager._base_dir = vp_dir
  842. original_mkdir = type(vp_dir).mkdir
  843. def mock_mkdir(self, *args, **kwargs):
  844. if "virtual_printer" in str(self):
  845. raise PermissionError("Permission denied")
  846. return original_mkdir(self, *args, **kwargs)
  847. with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), "mkdir", mock_mkdir):
  848. manager._ensure_base_directories()
  849. assert "Permission denied" in caplog.text
  850. def test_instance_creates_per_vp_directories(self, tmp_path):
  851. """Verify VirtualPrinterInstance creates per-VP upload and cert dirs."""
  852. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  853. VirtualPrinterInstance(
  854. vp_id=42,
  855. name="Test",
  856. mode="immediate",
  857. model="C11",
  858. access_code="12345678",
  859. serial_suffix="391800042",
  860. base_dir=tmp_path,
  861. )
  862. assert (tmp_path / "uploads" / "42").exists()
  863. assert (tmp_path / "uploads" / "42" / "cache").exists()
  864. assert (tmp_path / "certs" / "42").exists()
  865. class TestVirtualPrinterInstanceProxyMode:
  866. """Tests for VirtualPrinterInstance proxy mode."""
  867. @pytest.fixture
  868. def proxy_instance(self, tmp_path):
  869. """Create a proxy-mode VirtualPrinterInstance."""
  870. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  871. return VirtualPrinterInstance(
  872. vp_id=10,
  873. name="ProxyTest",
  874. mode="proxy",
  875. model="C11",
  876. access_code="",
  877. serial_suffix="391800010",
  878. target_printer_ip="192.168.1.100",
  879. target_printer_serial="01P00A000000001",
  880. base_dir=tmp_path,
  881. )
  882. def test_proxy_instance_properties(self, proxy_instance):
  883. """Verify proxy instance stores config correctly."""
  884. assert proxy_instance.is_proxy is True
  885. assert proxy_instance.mode == "proxy"
  886. assert proxy_instance.target_printer_ip == "192.168.1.100"
  887. assert proxy_instance.target_printer_serial == "01P00A000000001"
  888. def test_proxy_instance_does_not_require_access_code(self, proxy_instance):
  889. """Verify proxy mode can have empty access code."""
  890. assert proxy_instance.access_code == ""
  891. def test_get_status_proxy_includes_proxy_fields(self, proxy_instance):
  892. """Verify get_status includes proxy fields when proxy is active."""
  893. mock_proxy = MagicMock()
  894. mock_proxy.get_status.return_value = {
  895. "running": True,
  896. "ftp_port": 990,
  897. "mqtt_port": 8883,
  898. "ftp_connections": 1,
  899. "mqtt_connections": 2,
  900. "target_host": "192.168.1.100",
  901. }
  902. proxy_instance._proxy = mock_proxy
  903. status = proxy_instance.get_status()
  904. assert "proxy" in status
  905. assert status["proxy"]["ftp_port"] == 990
  906. assert status["proxy"]["mqtt_connections"] == 2
  907. def test_proxy_instance_stores_remote_interface(self, tmp_path):
  908. """Verify proxy instance stores remote_interface_ip."""
  909. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  910. inst = VirtualPrinterInstance(
  911. vp_id=11,
  912. name="Proxy2",
  913. mode="proxy",
  914. model="C11",
  915. access_code="",
  916. serial_suffix="391800011",
  917. target_printer_ip="192.168.1.100",
  918. remote_interface_ip="10.0.0.50",
  919. base_dir=tmp_path,
  920. )
  921. assert inst.remote_interface_ip == "10.0.0.50"
  922. class TestVirtualPrinterInstanceIPOverride:
  923. """Tests for remote_interface_ip and bind_ip on VirtualPrinterInstance."""
  924. @pytest.fixture
  925. def instance_with_remote_ip(self, tmp_path):
  926. """Create an instance with remote_interface_ip set."""
  927. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  928. return VirtualPrinterInstance(
  929. vp_id=20,
  930. name="IPTest",
  931. mode="immediate",
  932. model="3DPrinter-X1-Carbon",
  933. access_code="12345678",
  934. serial_suffix="391800020",
  935. bind_ip="192.168.1.50",
  936. remote_interface_ip="10.0.0.50",
  937. base_dir=tmp_path,
  938. )
  939. def test_instance_stores_bind_ip(self, instance_with_remote_ip):
  940. """Verify bind_ip is stored."""
  941. assert instance_with_remote_ip.bind_ip == "192.168.1.50"
  942. def test_instance_stores_remote_interface_ip(self, instance_with_remote_ip):
  943. """Verify remote_interface_ip is stored."""
  944. assert instance_with_remote_ip.remote_interface_ip == "10.0.0.50"
  945. def test_generate_certificates_includes_remote_and_bind_ip(self, instance_with_remote_ip):
  946. """Verify generate_certificates passes remote_interface_ip and bind_ip as SANs."""
  947. with (
  948. patch.object(instance_with_remote_ip._cert_service, "delete_printer_certificate"),
  949. patch.object(
  950. instance_with_remote_ip._cert_service,
  951. "generate_certificates",
  952. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  953. ) as mock_gen,
  954. ):
  955. instance_with_remote_ip.generate_certificates()
  956. mock_gen.assert_called_once_with(additional_ips=["10.0.0.50", "192.168.1.50"])
  957. def test_generate_certificates_no_remote_ip(self, tmp_path):
  958. """Verify generate_certificates passes only bind_ip when no remote_interface_ip."""
  959. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  960. inst = VirtualPrinterInstance(
  961. vp_id=21,
  962. name="NoRemote",
  963. mode="immediate",
  964. model="3DPrinter-X1-Carbon",
  965. access_code="12345678",
  966. serial_suffix="391800021",
  967. bind_ip="192.168.1.50",
  968. base_dir=tmp_path,
  969. )
  970. with (
  971. patch.object(inst._cert_service, "delete_printer_certificate"),
  972. patch.object(
  973. inst._cert_service,
  974. "generate_certificates",
  975. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  976. ) as mock_gen,
  977. ):
  978. inst.generate_certificates()
  979. mock_gen.assert_called_once_with(additional_ips=["192.168.1.50"])
  980. def test_generate_certificates_no_ips(self, tmp_path):
  981. """Verify generate_certificates passes None when no IPs configured."""
  982. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  983. inst = VirtualPrinterInstance(
  984. vp_id=22,
  985. name="NoIPs",
  986. mode="immediate",
  987. model="3DPrinter-X1-Carbon",
  988. access_code="12345678",
  989. serial_suffix="391800022",
  990. base_dir=tmp_path,
  991. )
  992. with (
  993. patch.object(inst._cert_service, "delete_printer_certificate"),
  994. patch.object(
  995. inst._cert_service,
  996. "generate_certificates",
  997. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  998. ) as mock_gen,
  999. ):
  1000. inst.generate_certificates()
  1001. mock_gen.assert_called_once_with(additional_ips=None)
  1002. class TestBindServer:
  1003. """Tests for the BindServer (port 3002 bind/detect protocol)."""
  1004. @pytest.fixture
  1005. def bind_server(self):
  1006. """Create a BindServer instance."""
  1007. from backend.app.services.virtual_printer.bind_server import BindServer
  1008. return BindServer(
  1009. serial="01S00C000000001",
  1010. model="3DPrinter-X1-Carbon",
  1011. name="Bambuddy",
  1012. )
  1013. def test_build_frame(self, bind_server):
  1014. """Verify frame format: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
  1015. payload = {"login": {"command": "detect"}}
  1016. frame = bind_server._build_frame(payload)
  1017. assert frame[:2] == b"\xa5\xa5"
  1018. assert frame[-2:] == b"\xa7\xa7"
  1019. # Length field is total message size
  1020. import struct
  1021. total_len = struct.unpack_from("<H", frame, 2)[0]
  1022. assert total_len == len(frame)
  1023. # JSON payload is between header and trailer
  1024. import json
  1025. json_bytes = frame[4:-2]
  1026. parsed = json.loads(json_bytes)
  1027. assert parsed == payload
  1028. def test_parse_frame_valid(self, bind_server):
  1029. """Verify valid frame parsing."""
  1030. frame = bind_server._build_frame({"login": {"command": "detect", "sequence_id": "20000"}})
  1031. result = bind_server._parse_frame(frame)
  1032. assert result is not None
  1033. assert result["login"]["command"] == "detect"
  1034. assert result["login"]["sequence_id"] == "20000"
  1035. def test_parse_frame_invalid_header(self, bind_server):
  1036. """Verify invalid header returns None."""
  1037. frame = b"\xb5\xb5\x10\x00" + b'{"login":{}}' + b"\xa7\xa7"
  1038. assert bind_server._parse_frame(frame) is None
  1039. def test_parse_frame_invalid_trailer(self, bind_server):
  1040. """Verify invalid trailer returns None."""
  1041. frame = b"\xa5\xa5\x10\x00" + b'{"login":{}}' + b"\xb7\xb7"
  1042. assert bind_server._parse_frame(frame) is None
  1043. def test_parse_frame_too_short(self, bind_server):
  1044. """Verify short data returns None."""
  1045. assert bind_server._parse_frame(b"\xa5\xa5\x00") is None
  1046. assert bind_server._parse_frame(b"") is None
  1047. def test_parse_frame_invalid_json(self, bind_server):
  1048. """Verify invalid JSON returns None."""
  1049. import struct
  1050. bad_json = b"not json"
  1051. total_len = 4 + len(bad_json) + 2
  1052. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
  1053. assert bind_server._parse_frame(frame) is None
  1054. def test_build_frame_roundtrip(self, bind_server):
  1055. """Verify build then parse roundtrip."""
  1056. original = {"login": {"bind": "free", "command": "detect", "id": "01S00C000000001"}}
  1057. frame = bind_server._build_frame(original)
  1058. parsed = bind_server._parse_frame(frame)
  1059. assert parsed == original
  1060. def test_bind_server_stores_config(self, bind_server):
  1061. """Verify config is stored correctly."""
  1062. assert bind_server.serial == "01S00C000000001"
  1063. assert bind_server.model == "3DPrinter-X1-Carbon"
  1064. assert bind_server.name == "Bambuddy"
  1065. assert bind_server.version == "01.00.00.00"
  1066. def test_bind_server_custom_version(self):
  1067. """Verify custom firmware version is stored."""
  1068. from backend.app.services.virtual_printer.bind_server import BindServer
  1069. server = BindServer(
  1070. serial="01S00C000000001",
  1071. model="3DPrinter-X1-Carbon",
  1072. name="Bambuddy",
  1073. version="01.09.00.10",
  1074. )
  1075. assert server.version == "01.09.00.10"
  1076. def test_bind_ports_includes_both(self):
  1077. """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
  1078. from backend.app.services.virtual_printer.bind_server import BIND_PORTS
  1079. assert 3000 in BIND_PORTS
  1080. assert 3002 in BIND_PORTS
  1081. def test_bind_server_initializes_empty_servers_list(self, bind_server):
  1082. """Verify bind server starts with empty servers list."""
  1083. assert bind_server._servers == []
  1084. assert bind_server._running is False
  1085. @pytest.mark.asyncio
  1086. async def test_start_server_creates_bind_server(self, tmp_path):
  1087. """Verify start_server creates BindServer with correct params."""
  1088. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1089. inst = VirtualPrinterInstance(
  1090. vp_id=99,
  1091. name="Bambuddy",
  1092. mode="immediate",
  1093. model="3DPrinter-X1-Carbon",
  1094. access_code="12345678",
  1095. serial_suffix="391800099",
  1096. bind_ip="192.168.1.50",
  1097. base_dir=tmp_path,
  1098. )
  1099. with (
  1100. patch("backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer"),
  1101. patch("backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer"),
  1102. patch("backend.app.services.virtual_printer.manager.SimpleMQTTServer"),
  1103. patch("backend.app.services.virtual_printer.manager.BindServer") as mock_bind_cls,
  1104. patch.object(inst._cert_service, "delete_printer_certificate"),
  1105. patch.object(
  1106. inst._cert_service,
  1107. "generate_certificates",
  1108. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  1109. ),
  1110. ):
  1111. await inst.start_server()
  1112. mock_bind_cls.assert_called_once_with(
  1113. serial=inst.serial,
  1114. model="3DPrinter-X1-Carbon",
  1115. name="Bambuddy",
  1116. bind_address="192.168.1.50",
  1117. cert_path=Path("/tmp/cert.pem"), # nosec B108
  1118. key_path=Path("/tmp/key.pem"), # nosec B108
  1119. )