test_support_helpers.py 65 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575
  1. """Unit tests for support module helper functions.
  2. Tests _anonymize_mqtt_broker, _check_port, _get_container_memory_limit,
  3. _format_bytes, and _collect_support_info diagnostic sections.
  4. """
  5. import asyncio
  6. import tempfile
  7. from pathlib import Path
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. class TestApplyLogLevel:
  11. """Tests for _apply_log_level() debug noise suppression."""
  12. def test_debug_mode_suppresses_sqlalchemy_to_warning(self):
  13. """Verify sqlalchemy.engine is set to WARNING (not INFO) in debug mode."""
  14. import logging
  15. from backend.app.api.routes.support import _apply_log_level
  16. _apply_log_level(True)
  17. assert logging.getLogger("sqlalchemy.engine").level == logging.WARNING
  18. def test_debug_mode_suppresses_aiosqlite(self):
  19. """Verify aiosqlite is set to WARNING in debug mode to prevent cursor noise."""
  20. import logging
  21. from backend.app.api.routes.support import _apply_log_level
  22. _apply_log_level(True)
  23. assert logging.getLogger("aiosqlite").level == logging.WARNING
  24. def test_debug_mode_keeps_httpx_pinned_to_warning(self):
  25. """httpx/httpcore must stay at WARNING even in debug mode — at INFO/DEBUG
  26. they log full request URLs, leaking webhook tokens (Discord etc.)."""
  27. import logging
  28. from backend.app.api.routes.support import _apply_log_level
  29. _apply_log_level(True)
  30. assert logging.getLogger("httpcore").level == logging.WARNING
  31. assert logging.getLogger("httpx").level == logging.WARNING
  32. def test_non_debug_mode_suppresses_all_noisy_loggers(self):
  33. """Verify all noisy loggers are set to WARNING in non-debug mode."""
  34. import logging
  35. from backend.app.api.routes.support import _apply_log_level
  36. _apply_log_level(False)
  37. assert logging.getLogger("sqlalchemy.engine").level == logging.WARNING
  38. assert logging.getLogger("httpcore").level == logging.WARNING
  39. assert logging.getLogger("httpx").level == logging.WARNING
  40. assert logging.getLogger("paho.mqtt").level == logging.WARNING
  41. class TestAnonymizeMqttBroker:
  42. """Tests for _anonymize_mqtt_broker()."""
  43. def test_empty_string(self):
  44. from backend.app.api.routes.support import _anonymize_mqtt_broker
  45. assert _anonymize_mqtt_broker("") == ""
  46. def test_ipv4_address(self):
  47. from backend.app.api.routes.support import _anonymize_mqtt_broker
  48. assert _anonymize_mqtt_broker("192.168.1.100") == "[IP]"
  49. def test_ipv6_address(self):
  50. from backend.app.api.routes.support import _anonymize_mqtt_broker
  51. assert _anonymize_mqtt_broker("::1") == "[IP]"
  52. def test_hostname_with_domain(self):
  53. from backend.app.api.routes.support import _anonymize_mqtt_broker
  54. assert _anonymize_mqtt_broker("mqtt.example.com") == "*.example.com"
  55. def test_hostname_with_subdomain(self):
  56. from backend.app.api.routes.support import _anonymize_mqtt_broker
  57. assert _anonymize_mqtt_broker("broker.mqtt.example.com") == "*.example.com"
  58. def test_single_part_hostname(self):
  59. from backend.app.api.routes.support import _anonymize_mqtt_broker
  60. assert _anonymize_mqtt_broker("localhost") == "localhost"
  61. class TestCheckPort:
  62. """Tests for _check_port()."""
  63. @pytest.mark.asyncio
  64. @pytest.mark.unit
  65. async def test_reachable_port(self):
  66. from backend.app.api.routes.support import _check_port
  67. # Mock a successful connection
  68. mock_writer = AsyncMock()
  69. mock_writer.close = MagicMock()
  70. mock_writer.wait_closed = AsyncMock()
  71. with patch("backend.app.api.routes.support.asyncio.open_connection", return_value=(AsyncMock(), mock_writer)):
  72. result = await _check_port("192.168.1.1", 8883, timeout=1.0)
  73. assert result is True
  74. @pytest.mark.asyncio
  75. @pytest.mark.unit
  76. async def test_unreachable_port(self):
  77. from backend.app.api.routes.support import _check_port
  78. with (
  79. patch(
  80. "backend.app.api.routes.support.asyncio.open_connection",
  81. side_effect=ConnectionRefusedError,
  82. ),
  83. patch(
  84. "backend.app.api.routes.support.asyncio.wait_for",
  85. side_effect=ConnectionRefusedError,
  86. ),
  87. ):
  88. result = await _check_port("192.168.1.1", 8883, timeout=1.0)
  89. assert result is False
  90. @pytest.mark.asyncio
  91. @pytest.mark.unit
  92. async def test_timeout(self):
  93. from backend.app.api.routes.support import _check_port
  94. with patch(
  95. "backend.app.api.routes.support.asyncio.wait_for",
  96. side_effect=asyncio.TimeoutError,
  97. ):
  98. result = await _check_port("192.168.1.1", 8883, timeout=0.1)
  99. assert result is False
  100. class TestGetContainerMemoryLimit:
  101. """Tests for _get_container_memory_limit()."""
  102. def test_cgroup_v2_with_limit(self):
  103. from backend.app.api.routes.support import _get_container_memory_limit
  104. with tempfile.TemporaryDirectory() as tmpdir:
  105. v2_path = Path(tmpdir) / "memory.max"
  106. v2_path.write_text("1073741824\n")
  107. with patch("backend.app.api.routes.support.Path") as mock_path:
  108. # v2 path exists with value
  109. v2_mock = MagicMock()
  110. v2_mock.exists.return_value = True
  111. v2_mock.read_text.return_value = "1073741824\n"
  112. v1_mock = MagicMock()
  113. v1_mock.exists.return_value = False
  114. mock_path.side_effect = lambda p: v2_mock if "memory.max" in p else v1_mock
  115. result = _get_container_memory_limit()
  116. assert result == 1073741824
  117. def test_cgroup_v2_unlimited(self):
  118. from backend.app.api.routes.support import _get_container_memory_limit
  119. with patch("backend.app.api.routes.support.Path") as mock_path:
  120. v2_mock = MagicMock()
  121. v2_mock.exists.return_value = True
  122. v2_mock.read_text.return_value = "max\n"
  123. v1_mock = MagicMock()
  124. v1_mock.exists.return_value = False
  125. mock_path.side_effect = lambda p: v2_mock if "memory.max" in p else v1_mock
  126. result = _get_container_memory_limit()
  127. assert result is None
  128. def test_no_cgroup_files(self):
  129. from backend.app.api.routes.support import _get_container_memory_limit
  130. with patch("backend.app.api.routes.support.Path") as mock_path:
  131. mock_instance = MagicMock()
  132. mock_instance.exists.return_value = False
  133. mock_path.return_value = mock_instance
  134. result = _get_container_memory_limit()
  135. assert result is None
  136. class TestFormatBytes:
  137. """Tests for _format_bytes()."""
  138. def test_bytes(self):
  139. from backend.app.api.routes.support import _format_bytes
  140. assert _format_bytes(500) == "500 B"
  141. def test_kilobytes(self):
  142. from backend.app.api.routes.support import _format_bytes
  143. assert _format_bytes(2048) == "2.0 KB"
  144. def test_megabytes(self):
  145. from backend.app.api.routes.support import _format_bytes
  146. assert _format_bytes(10 * 1024 * 1024) == "10.0 MB"
  147. def test_gigabytes(self):
  148. from backend.app.api.routes.support import _format_bytes
  149. assert _format_bytes(2 * 1024 * 1024 * 1024) == "2.00 GB"
  150. def test_zero(self):
  151. from backend.app.api.routes.support import _format_bytes
  152. assert _format_bytes(0) == "0 B"
  153. class TestSanitizeLogContent:
  154. """Tests for _sanitize_log_content() redaction."""
  155. def test_ipv4_addresses_redacted(self):
  156. """IPv4 addresses in log lines are replaced with [IP]."""
  157. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  158. content = "2024-01-15 Connected to printer at 192.168.1.100 on port 8883"
  159. result = _sanitize_log_content(content)
  160. assert "192.168.1.100" not in result
  161. assert "[IP]" in result
  162. assert "on port 8883" in result
  163. def test_multiple_ipv4_addresses_redacted(self):
  164. """Multiple different IPs in the same line are all redacted."""
  165. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  166. content = "Proxy 10.0.0.1 -> 192.168.1.50"
  167. result = _sanitize_log_content(content)
  168. assert result == "Proxy [IP] -> [IP]"
  169. def test_firmware_versions_with_leading_zeros_preserved(self):
  170. """Firmware versions like 01.09.01.00 have leading zeros and should NOT be redacted."""
  171. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  172. content = "Firmware version: 01.09.01.00"
  173. result = _sanitize_log_content(content)
  174. assert "01.09.01.00" in result
  175. def test_firmware_version_mixed_with_ip(self):
  176. """Firmware versions preserved while real IPs are redacted in the same line."""
  177. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  178. content = "Printer at 192.168.1.5 running firmware 01.07.02.00"
  179. result = _sanitize_log_content(content)
  180. assert "192.168.1.5" not in result
  181. assert "01.07.02.00" in result
  182. assert "[IP] running firmware 01.07.02.00" in result
  183. def test_printer_ip_from_sensitive_strings(self):
  184. """Printer IPs in sensitive_strings are replaced before regex pass."""
  185. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  186. content = "Connecting to 192.168.1.100"
  187. result = _sanitize_log_content(content, sensitive_strings={"192.168.1.100": "[IP]"})
  188. assert result == "Connecting to [IP]"
  189. def test_edge_case_zero_ip(self):
  190. """0.0.0.0 is a valid IP and should be redacted."""
  191. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  192. content = "Binding to 0.0.0.0"
  193. result = _sanitize_log_content(content)
  194. assert result == "Binding to [IP]"
  195. def test_edge_case_broadcast_ip(self):
  196. """255.255.255.255 is a valid IP and should be redacted."""
  197. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  198. content = "Broadcast to 255.255.255.255"
  199. result = _sanitize_log_content(content)
  200. assert result == "Broadcast to [IP]"
  201. def test_invalid_octet_not_redacted(self):
  202. """Octets >255 are not valid IPs and should not be redacted."""
  203. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  204. content = "Value 999.999.999.999"
  205. result = _sanitize_log_content(content)
  206. assert "999.999.999.999" in result
  207. def test_existing_serial_redaction_still_works(self):
  208. """Serial number redaction still functions alongside IP redaction."""
  209. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  210. content = "Printer 01SABCDEF1234 at 10.0.0.5"
  211. result = _sanitize_log_content(content)
  212. assert "[SERIAL]" in result
  213. assert "[IP]" in result
  214. assert "01SABCDEF1234" not in result
  215. assert "10.0.0.5" not in result
  216. def test_existing_email_redaction_still_works(self):
  217. """Email redaction still functions alongside IP redaction."""
  218. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  219. content = "User user@example.com from 172.16.0.1"
  220. result = _sanitize_log_content(content)
  221. assert "[EMAIL]" in result
  222. assert "[IP]" in result
  223. def test_existing_path_redaction_still_works(self):
  224. """Path redaction still functions alongside IP redaction."""
  225. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  226. content = "Config at /home/john/config.yaml from 192.168.0.1"
  227. result = _sanitize_log_content(content)
  228. assert "/home/[user]/" in result
  229. assert "[IP]" in result
  230. def test_ldap_dn_redacted_reporter_line(self):
  231. """#2681: the exact reporter line — the CN (real name) must not survive."""
  232. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  233. content = (
  234. "LDAP authentication successful for user: jschmoe "
  235. "(DN: CN=Joe Schmoe,CN=Users,DC=ad,DC=example,DC=com, groups: 4)"
  236. )
  237. result = _sanitize_log_content(content)
  238. assert "Joe Schmoe" not in result
  239. assert "DC=example" not in result
  240. assert result == "LDAP authentication successful for user: jschmoe (DN: [DN], groups: 4)"
  241. def test_ldap_dn_redacted_in_exception_string(self):
  242. """DNs that leak indirectly via ldap3 exception text are caught too."""
  243. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  244. content = "LDAP bind failed for user jschmoe: invalidCredentials at uid=jschmoe,ou=people,dc=example,dc=org"
  245. result = _sanitize_log_content(content)
  246. assert "uid=jschmoe" not in result
  247. assert "[DN]" in result
  248. def test_ldap_group_dn_redacted(self):
  249. """Group DNs (from group-mapping logs) are PII-bearing and redacted."""
  250. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  251. content = "Mapped CN=Admins,OU=Groups,DC=corp,DC=local -> Administrators"
  252. result = _sanitize_log_content(content)
  253. assert "CN=Admins" not in result
  254. assert "DC=corp" not in result
  255. assert "[DN]" in result
  256. assert "Administrators" in result # the non-PII target group name survives
  257. def test_non_dn_key_value_line_not_clobbered(self):
  258. """An ordinary key=value log line must not be mistaken for a DN."""
  259. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  260. content = "Dispatch decision: mode=queue, state=FINISH, printer=1"
  261. result = _sanitize_log_content(content)
  262. assert result == content
  263. def test_single_rdn_not_redacted(self):
  264. """A lone attr=value (not a multi-component DN) is left alone."""
  265. from backend.app.services.log_reader import sanitize_log_content as _sanitize_log_content
  266. content = "Country C=US selected"
  267. result = _sanitize_log_content(content)
  268. assert result == content
  269. class TestCollectSupportInfo:
  270. """Tests for _collect_support_info() new diagnostic sections."""
  271. @pytest.mark.asyncio
  272. @pytest.mark.unit
  273. async def test_environment_has_timezone(self):
  274. """Verify environment section includes timezone."""
  275. from backend.app.api.routes.support import _collect_support_info
  276. with (
  277. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  278. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  279. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  280. patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
  281. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  282. patch.dict("os.environ", {"TZ": "America/New_York"}),
  283. ):
  284. mock_pm.get_all_statuses.return_value = {}
  285. mock_ws.active_connections = []
  286. mock_db = AsyncMock()
  287. mock_result = MagicMock()
  288. mock_result.scalar.return_value = 0
  289. mock_result.scalar_one_or_none.return_value = None
  290. mock_result.scalars.return_value.all.return_value = []
  291. mock_db.execute = AsyncMock(return_value=mock_result)
  292. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  293. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  294. info = await _collect_support_info()
  295. assert info["environment"]["timezone"] == "America/New_York"
  296. assert info["environment"]["docker"] is False
  297. @pytest.mark.asyncio
  298. @pytest.mark.unit
  299. async def test_docker_section_present_when_in_docker(self):
  300. """Verify docker section is added when running in Docker."""
  301. from backend.app.api.routes.support import _collect_support_info
  302. with (
  303. patch("backend.app.api.routes.support.is_running_in_docker", return_value=True),
  304. patch("backend.app.api.routes.support._get_container_memory_limit", return_value=1073741824),
  305. patch("backend.app.api.routes.support._detect_docker_network_mode", return_value="bridge"),
  306. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  307. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  308. patch(
  309. "backend.app.api.routes.support.get_network_interfaces",
  310. return_value=[{"name": "eth0", "subnet": "172.17.0.0/16"}],
  311. ),
  312. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  313. ):
  314. mock_pm.get_all_statuses.return_value = {}
  315. mock_ws.active_connections = []
  316. mock_db = AsyncMock()
  317. mock_result = MagicMock()
  318. mock_result.scalar.return_value = 0
  319. mock_result.scalar_one_or_none.return_value = None
  320. mock_result.scalars.return_value.all.return_value = []
  321. mock_db.execute = AsyncMock(return_value=mock_result)
  322. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  323. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  324. info = await _collect_support_info()
  325. assert "docker" in info
  326. assert info["docker"]["container_memory_limit_bytes"] == 1073741824
  327. assert info["docker"]["container_memory_limit_formatted"] == "1.00 GB"
  328. assert info["docker"]["network_mode_hint"] == "bridge"
  329. @pytest.mark.asyncio
  330. @pytest.mark.unit
  331. async def test_docker_section_absent_when_not_docker(self):
  332. """Verify docker section is absent when not in Docker."""
  333. from backend.app.api.routes.support import _collect_support_info
  334. with (
  335. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  336. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  337. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  338. patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
  339. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  340. ):
  341. mock_pm.get_all_statuses.return_value = {}
  342. mock_ws.active_connections = []
  343. mock_db = AsyncMock()
  344. mock_result = MagicMock()
  345. mock_result.scalar.return_value = 0
  346. mock_result.scalar_one_or_none.return_value = None
  347. mock_result.scalars.return_value.all.return_value = []
  348. mock_db.execute = AsyncMock(return_value=mock_result)
  349. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  350. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  351. info = await _collect_support_info()
  352. assert "docker" not in info
  353. @pytest.mark.asyncio
  354. @pytest.mark.unit
  355. async def test_dependencies_section(self):
  356. """Verify dependencies section lists package versions."""
  357. from backend.app.api.routes.support import _collect_support_info
  358. with (
  359. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  360. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  361. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  362. patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
  363. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  364. ):
  365. mock_pm.get_all_statuses.return_value = {}
  366. mock_ws.active_connections = []
  367. mock_db = AsyncMock()
  368. mock_result = MagicMock()
  369. mock_result.scalar.return_value = 0
  370. mock_result.scalar_one_or_none.return_value = None
  371. mock_result.scalars.return_value.all.return_value = []
  372. mock_db.execute = AsyncMock(return_value=mock_result)
  373. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  374. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  375. info = await _collect_support_info()
  376. assert "dependencies" in info
  377. # fastapi should be installed in test environment
  378. assert "fastapi" in info["dependencies"]
  379. assert info["dependencies"]["fastapi"] is not None
  380. @pytest.mark.asyncio
  381. @pytest.mark.unit
  382. async def test_websockets_section(self):
  383. """Verify websockets section shows connection count."""
  384. from backend.app.api.routes.support import _collect_support_info
  385. with (
  386. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  387. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  388. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  389. patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
  390. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  391. ):
  392. mock_pm.get_all_statuses.return_value = {}
  393. mock_ws.active_connections = ["conn1", "conn2"]
  394. mock_db = AsyncMock()
  395. mock_result = MagicMock()
  396. mock_result.scalar.return_value = 0
  397. mock_result.scalar_one_or_none.return_value = None
  398. mock_result.scalars.return_value.all.return_value = []
  399. mock_db.execute = AsyncMock(return_value=mock_result)
  400. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  401. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  402. info = await _collect_support_info()
  403. assert info["websockets"]["active_connections"] == 2
  404. @pytest.mark.asyncio
  405. @pytest.mark.unit
  406. async def test_network_section(self):
  407. """Verify network section shows interface subnets."""
  408. from backend.app.api.routes.support import _collect_support_info
  409. mock_interfaces = [
  410. {"name": "eth0", "ip": "192.168.1.100", "netmask": "255.255.255.0", "subnet": "192.168.1.0/24"},
  411. {"name": "wlan0", "ip": "10.0.0.50", "netmask": "255.255.255.0", "subnet": "10.0.0.0/24"},
  412. ]
  413. with (
  414. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  415. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  416. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  417. patch("backend.app.api.routes.support.get_network_interfaces", return_value=mock_interfaces),
  418. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  419. ):
  420. mock_pm.get_all_statuses.return_value = {}
  421. mock_ws.active_connections = []
  422. mock_db = AsyncMock()
  423. mock_result = MagicMock()
  424. mock_result.scalar.return_value = 0
  425. mock_result.scalar_one_or_none.return_value = None
  426. mock_result.scalars.return_value.all.return_value = []
  427. mock_db.execute = AsyncMock(return_value=mock_result)
  428. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  429. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  430. info = await _collect_support_info()
  431. assert info["network"]["interface_count"] == 2
  432. assert info["network"]["interfaces"][0]["name"] == "eth0"
  433. assert info["network"]["interfaces"][0]["subnet"] == "x.x.1.0/24"
  434. # Verify IP addresses are NOT included (first two octets masked)
  435. for iface in info["network"]["interfaces"]:
  436. assert "ip" not in iface
  437. assert iface["subnet"].startswith("x.x.")
  438. @pytest.mark.asyncio
  439. @pytest.mark.unit
  440. async def test_log_file_section(self):
  441. """Verify log file section shows size info."""
  442. from backend.app.api.routes.support import _collect_support_info
  443. with tempfile.TemporaryDirectory() as tmpdir:
  444. log_dir = Path(tmpdir)
  445. log_file = log_dir / "bambuddy.log"
  446. log_file.write_text("some log content\n" * 100)
  447. with (
  448. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  449. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  450. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  451. patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
  452. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  453. patch("backend.app.api.routes.support.settings") as mock_settings,
  454. ):
  455. mock_settings.base_dir = Path(tmpdir)
  456. mock_settings.log_dir = log_dir
  457. mock_settings.debug = False
  458. mock_pm.get_all_statuses.return_value = {}
  459. mock_ws.active_connections = []
  460. mock_db = AsyncMock()
  461. mock_result = MagicMock()
  462. mock_result.scalar.return_value = 0
  463. mock_result.scalar_one_or_none.return_value = None
  464. mock_result.scalars.return_value.all.return_value = []
  465. mock_db.execute = AsyncMock(return_value=mock_result)
  466. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  467. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  468. info = await _collect_support_info()
  469. assert "log_file" in info
  470. assert info["log_file"]["size_bytes"] > 0
  471. assert "B" in info["log_file"]["size_formatted"] or "KB" in info["log_file"]["size_formatted"]
  472. @pytest.mark.asyncio
  473. @pytest.mark.unit
  474. async def test_settings_include_all_keys_with_sensitive_redacted(self):
  475. """All settings keys must appear in output; sensitive values are replaced with [REDACTED]."""
  476. from backend.app.api.routes.support import _collect_support_info
  477. fake_settings = [
  478. MagicMock(key="benign_flag", value="true"),
  479. MagicMock(key="bambu_cloud_token", value="super-secret"),
  480. MagicMock(key="github_webhook", value="https://hooks.example/abc"),
  481. MagicMock(key="empty_password", value=""),
  482. MagicMock(key="local_backup_path", value="/data/backups"),
  483. # Regression: setting was leaking before the `broker` keyword was added.
  484. MagicMock(key="mqtt_broker", value="192.168.255.16"),
  485. # Regression: setting was leaking before the `auth_key` keyword was
  486. # added — and a value-prefix safety net (`tskey-`) was introduced
  487. # so future Tailscale settings auto-redact even if we forget the key.
  488. MagicMock(key="virtual_printer_tailscale_auth_key", value="tskey-auth-secrettokenhere"),
  489. # Value-prefix safety net standalone: a hypothetical future setting
  490. # named without "auth_key" but whose value starts with the Tailscale
  491. # prefix must still redact.
  492. MagicMock(key="some_future_ts_setting", value="tskey-other-secret"),
  493. ]
  494. def make_result(rows=None):
  495. r = MagicMock()
  496. r.scalar.return_value = 0
  497. r.scalar_one_or_none.return_value = None
  498. r.scalars.return_value.all.return_value = rows or []
  499. r.all.return_value = []
  500. return r
  501. async def fake_execute(stmt, *_a, **_kw):
  502. sql = str(stmt).lower()
  503. # Route by table name in the compiled SQL
  504. if "from settings" in sql or "settings.key" in sql:
  505. return make_result(fake_settings)
  506. return make_result([])
  507. with (
  508. tempfile.TemporaryDirectory() as tmpdir,
  509. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  510. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  511. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  512. patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
  513. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  514. patch("backend.app.api.routes.support.settings") as mock_settings,
  515. ):
  516. mock_settings.base_dir = Path(tmpdir)
  517. mock_settings.log_dir = Path(tmpdir)
  518. mock_settings.debug = False
  519. mock_pm.get_all_statuses.return_value = {}
  520. mock_ws.active_connections = []
  521. mock_db = AsyncMock()
  522. mock_db.execute = fake_execute
  523. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  524. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  525. info = await _collect_support_info()
  526. s = info["settings"]
  527. assert s.get("bambu_cloud_token") == "[REDACTED]"
  528. assert s.get("github_webhook") == "[REDACTED]"
  529. assert s.get("local_backup_path") == "[REDACTED]"
  530. assert s.get("empty_password") == ""
  531. assert s.get("benign_flag") == "true"
  532. assert s.get("mqtt_broker") == "[REDACTED]"
  533. assert s.get("virtual_printer_tailscale_auth_key") == "[REDACTED]"
  534. assert s.get("some_future_ts_setting") == "[REDACTED]"
  535. class TestParseObicoEnabledPrinters:
  536. """Tests for the per-printer obico flag parser used by the bundle.
  537. The setting is written by the settings UI as a JSON array and read by
  538. ObicoDetectionService._load_settings as one; the bundle used to split it on
  539. commas and call empty "no printers", so a default Obico setup was reported
  540. as monitoring nothing while it was in fact monitoring everything (#2733).
  541. """
  542. def test_empty_means_all_printers(self):
  543. from backend.app.api.routes.support import _parse_obico_enabled_printers
  544. # None (not "no printers") — the same convention _load_settings uses.
  545. assert _parse_obico_enabled_printers("") is None
  546. assert _parse_obico_enabled_printers(" ") is None
  547. assert _parse_obico_enabled_printers(None) is None
  548. def test_json_array_is_the_stored_shape(self):
  549. from backend.app.api.routes.support import _parse_obico_enabled_printers
  550. assert _parse_obico_enabled_printers("[1, 2, 3]") == {1, 2, 3}
  551. assert _parse_obico_enabled_printers("[]") == set()
  552. def test_comma_separated_ids_still_parse(self):
  553. # Legacy fallback for any install that stored the old shape.
  554. from backend.app.api.routes.support import _parse_obico_enabled_printers
  555. assert _parse_obico_enabled_printers("1,2,3") == {1, 2, 3}
  556. assert _parse_obico_enabled_printers("1, 2 ,3") == {1, 2, 3}
  557. def test_non_integer_tokens_are_skipped(self):
  558. # Defensive against legacy/manually-edited setting values.
  559. from backend.app.api.routes.support import _parse_obico_enabled_printers
  560. assert _parse_obico_enabled_printers("1,abc,2") == {1, 2}
  561. assert _parse_obico_enabled_printers(",,1,") == {1}
  562. assert _parse_obico_enabled_printers('[1, "two", 3]') == {1, 3}
  563. def test_json_object_is_not_a_printer_list(self):
  564. from backend.app.api.routes.support import _parse_obico_enabled_printers
  565. # Falls through to the comma parser, which finds no integers.
  566. assert _parse_obico_enabled_printers('{"1": true}') == set()
  567. class TestCheckUrlReachable:
  568. """Tests for the slicer-API reachability ping."""
  569. @pytest.mark.asyncio
  570. async def test_empty_url_returns_none(self):
  571. from backend.app.api.routes.support import _check_url_reachable
  572. assert await _check_url_reachable("") is None
  573. assert await _check_url_reachable(" ") is None
  574. @pytest.mark.asyncio
  575. async def test_successful_response_is_reachable_even_on_404(self):
  576. # A 404 means the API is up; we want to separate network failure from
  577. # configuration mistakes, so non-empty status counts as reachable.
  578. from backend.app.api.routes.support import _check_url_reachable
  579. with patch("httpx.AsyncClient") as mock_client_cls:
  580. mock_client = AsyncMock()
  581. mock_client_cls.return_value.__aenter__.return_value = mock_client
  582. mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
  583. mock_response = MagicMock()
  584. mock_response.status_code = 404
  585. mock_client.get = AsyncMock(return_value=mock_response)
  586. result = await _check_url_reachable("http://localhost:3001/api")
  587. assert result is True
  588. @pytest.mark.asyncio
  589. async def test_connection_error_returns_false(self):
  590. from backend.app.api.routes.support import _check_url_reachable
  591. with patch("httpx.AsyncClient") as mock_client_cls:
  592. mock_client_cls.return_value.__aenter__.side_effect = ConnectionError("boom")
  593. result = await _check_url_reachable("http://nowhere:9999")
  594. assert result is False
  595. class TestFetchSlicerHealth:
  596. """Tests for the slicer-API health probe that extracts the bundled CLI
  597. version. Knowing the version in the support bundle lets the reviewer
  598. confirm the user is running the image they think they are — exactly the
  599. diagnostic that was missing when issue #1312 surfaced."""
  600. def _mock_httpx(self, status_code: int, body):
  601. """Construct a patched httpx.AsyncClient that returns a fixed response."""
  602. mock_client_cls = MagicMock()
  603. mock_client = AsyncMock()
  604. mock_client_cls.return_value.__aenter__.return_value = mock_client
  605. mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
  606. mock_response = MagicMock()
  607. mock_response.status_code = status_code
  608. if isinstance(body, Exception):
  609. mock_response.json.side_effect = body
  610. else:
  611. mock_response.json.return_value = body
  612. mock_client.get = AsyncMock(return_value=mock_response)
  613. return mock_client_cls, mock_client
  614. @pytest.mark.asyncio
  615. async def test_empty_url_returns_none(self):
  616. from backend.app.api.routes.support import _fetch_slicer_health
  617. assert await _fetch_slicer_health("") is None
  618. assert await _fetch_slicer_health(" ") is None
  619. @pytest.mark.asyncio
  620. async def test_parses_version_from_orcaslicer_field(self):
  621. """The default sidecar wrapper labels both orca and bambu CLIs under
  622. ``checks.orcaslicer``. The probe must read whichever non-dataPath child
  623. carries a ``version`` field instead of hardcoding the field name."""
  624. from backend.app.api.routes.support import _fetch_slicer_health
  625. body = {
  626. "status": "healthy",
  627. "checks": {
  628. "orcaslicer": {"available": True, "version": "2.3.2"},
  629. "dataPath": {"accessible": True},
  630. },
  631. }
  632. mock_client_cls, mock_client = self._mock_httpx(200, body)
  633. with patch("httpx.AsyncClient", mock_client_cls):
  634. result = await _fetch_slicer_health("http://orca:3003")
  635. assert result == {"reachable": True, "version": "2.3.2"}
  636. # And the URL was actually composed as /health.
  637. mock_client.get.assert_awaited_once()
  638. assert mock_client.get.await_args[0][0] == "http://orca:3003/health"
  639. @pytest.mark.asyncio
  640. async def test_parses_version_when_wrapper_uses_bambustudio_field(self):
  641. """Future-proofing: if the wrapper is ever fixed to label the bambu CLI
  642. as ``bambustudio``, the probe must still pick up the version. The probe
  643. walks every non-dataPath key looking for a ``version`` field rather
  644. than hardcoding the slicer name."""
  645. from backend.app.api.routes.support import _fetch_slicer_health
  646. body = {
  647. "status": "healthy",
  648. "checks": {
  649. "bambustudio": {"available": True, "version": "02.06.00.51"},
  650. "dataPath": {"accessible": True},
  651. },
  652. }
  653. mock_client_cls, _ = self._mock_httpx(200, body)
  654. with patch("httpx.AsyncClient", mock_client_cls):
  655. result = await _fetch_slicer_health("http://bs:3001")
  656. assert result == {"reachable": True, "version": "02.06.00.51"}
  657. @pytest.mark.asyncio
  658. async def test_version_unknown_propagates_as_string(self):
  659. """The wrapper emits literal ``"unknown"`` when it can't parse the
  660. slicer's --help output. We surface that as-is — it's diagnostic on
  661. its own (tells the reviewer the regex didn't match)."""
  662. from backend.app.api.routes.support import _fetch_slicer_health
  663. body = {
  664. "status": "healthy",
  665. "checks": {
  666. "orcaslicer": {"available": True, "version": "unknown"},
  667. "dataPath": {"accessible": True},
  668. },
  669. }
  670. mock_client_cls, _ = self._mock_httpx(200, body)
  671. with patch("httpx.AsyncClient", mock_client_cls):
  672. result = await _fetch_slicer_health("http://bs:3001")
  673. assert result == {"reachable": True, "version": "unknown"}
  674. @pytest.mark.asyncio
  675. async def test_non_200_status_is_reachable_but_no_version(self):
  676. """If the URL responds with a non-200, the host is up but the endpoint
  677. isn't the expected one — surface reachable=True so the reviewer can
  678. spot misconfiguration without conflating it with a network failure."""
  679. from backend.app.api.routes.support import _fetch_slicer_health
  680. mock_client_cls, _ = self._mock_httpx(404, {})
  681. with patch("httpx.AsyncClient", mock_client_cls):
  682. result = await _fetch_slicer_health("http://bs:3001")
  683. assert result == {"reachable": True, "version": None}
  684. @pytest.mark.asyncio
  685. async def test_malformed_json_returns_reachable_no_version(self):
  686. from backend.app.api.routes.support import _fetch_slicer_health
  687. mock_client_cls, _ = self._mock_httpx(200, ValueError("not json"))
  688. with patch("httpx.AsyncClient", mock_client_cls):
  689. result = await _fetch_slicer_health("http://bs:3001")
  690. assert result == {"reachable": True, "version": None}
  691. @pytest.mark.asyncio
  692. async def test_missing_checks_block_returns_no_version(self):
  693. from backend.app.api.routes.support import _fetch_slicer_health
  694. mock_client_cls, _ = self._mock_httpx(200, {"status": "healthy"})
  695. with patch("httpx.AsyncClient", mock_client_cls):
  696. result = await _fetch_slicer_health("http://bs:3001")
  697. assert result == {"reachable": True, "version": None}
  698. @pytest.mark.asyncio
  699. async def test_connection_error_returns_unreachable(self):
  700. from backend.app.api.routes.support import _fetch_slicer_health
  701. with patch("httpx.AsyncClient") as mock_client_cls:
  702. mock_client_cls.return_value.__aenter__.side_effect = ConnectionError("boom")
  703. result = await _fetch_slicer_health("http://nowhere:9999")
  704. assert result == {"reachable": False, "version": None}
  705. @pytest.mark.asyncio
  706. async def test_strips_trailing_slash_before_appending_health(self):
  707. """Defensive: URLs entered with trailing slashes in Settings should
  708. still produce a well-formed /health URL (no double-slash)."""
  709. from backend.app.api.routes.support import _fetch_slicer_health
  710. body = {"status": "healthy", "checks": {"orcaslicer": {"available": True, "version": "2.3.2"}}}
  711. mock_client_cls, mock_client = self._mock_httpx(200, body)
  712. with patch("httpx.AsyncClient", mock_client_cls):
  713. await _fetch_slicer_health("http://bs:3001/")
  714. assert mock_client.get.await_args[0][0] == "http://bs:3001/health"
  715. class TestCollectSlicerApiInfo:
  716. """Tests for the slicer-API info block (configured URLs + reachability).
  717. The collector reads URLs DIRECTLY from the DB rather than from the
  718. already-redacted ``info["settings"]`` dict — the previous version was
  719. pinging the literal string "[REDACTED]" (which httpx rejects) and getting
  720. ``False`` for any installation that actually had a slicer-API configured.
  721. These tests inject the raw URLs via a mocked `async_session` so the
  722. collector sees them as if they came from the unredacted Settings table.
  723. """
  724. def _make_settings_session(self, settings_dict):
  725. rows = [MagicMock(key=k, value=v) for k, v in settings_dict.items()]
  726. result = MagicMock()
  727. result.scalars.return_value.all.return_value = rows
  728. mock_db = AsyncMock()
  729. mock_db.execute = AsyncMock(return_value=result)
  730. ctx = MagicMock()
  731. ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  732. ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  733. return ctx
  734. @pytest.mark.asyncio
  735. async def test_disabled_does_not_run_reachability_check(self):
  736. from backend.app.api.routes.support import _collect_slicer_api_info
  737. session_ctx = self._make_settings_session({"use_slicer_api": "false", "preferred_slicer": "bambu_studio"})
  738. with (
  739. patch("backend.app.api.routes.support.async_session", session_ctx),
  740. patch("backend.app.api.routes.support._fetch_slicer_health") as mock_health,
  741. ):
  742. info = await _collect_slicer_api_info()
  743. mock_health.assert_not_called()
  744. assert info["enabled"] is False
  745. assert info["preferred"] == "bambu_studio"
  746. assert info["bambu_studio_url_set_in_db"] is False
  747. assert info["orcaslicer_url_set_in_db"] is False
  748. assert "bambu_studio_reachable" not in info
  749. assert "orcaslicer_reachable" not in info
  750. assert "bambu_studio_version" not in info
  751. assert "orcaslicer_version" not in info
  752. @pytest.mark.asyncio
  753. async def test_enabled_runs_reachability_check_for_both_urls(self):
  754. from backend.app.api.routes.support import _collect_slicer_api_info
  755. async def fake_health(url, timeout=2.0):
  756. if "orca" in url:
  757. return {"reachable": True, "version": "2.3.2"}
  758. return {"reachable": False, "version": None}
  759. session_ctx = self._make_settings_session(
  760. {
  761. "use_slicer_api": "true",
  762. "preferred_slicer": "orcaslicer",
  763. "bambu_studio_api_url": "http://bs:3001",
  764. "orcaslicer_api_url": "http://orca:3003",
  765. }
  766. )
  767. with (
  768. patch("backend.app.api.routes.support.async_session", session_ctx),
  769. patch("backend.app.api.routes.support._fetch_slicer_health", side_effect=fake_health),
  770. ):
  771. info = await _collect_slicer_api_info()
  772. assert info["enabled"] is True
  773. assert info["bambu_studio_url_set_in_db"] is True
  774. assert info["orcaslicer_url_set_in_db"] is True
  775. assert info["bambu_studio_url_source"] == "db"
  776. assert info["orcaslicer_url_source"] == "db"
  777. assert info["bambu_studio_reachable"] is False
  778. assert info["orcaslicer_reachable"] is True
  779. assert info["bambu_studio_version"] is None
  780. assert info["orcaslicer_version"] == "2.3.2"
  781. @pytest.mark.asyncio
  782. async def test_env_var_fallback_url_pinged_when_db_setting_empty(self):
  783. """Regression for the second pass on #support-bundle audit: the
  784. previous version returned `null` for `bambu_studio_reachable` on every
  785. installation that ran the sidecar via env var rather than via the DB
  786. setting (the common case for the default `http://localhost:3001`).
  787. The resolver now mirrors the precedence used by `archives.py:3174-3180`
  788. — DB setting first, then `app_settings.bambu_studio_api_url` (which
  789. reads the `BAMBU_STUDIO_API_URL` env var or the built-in default).
  790. """
  791. from backend.app.api.routes.support import _collect_slicer_api_info
  792. seen_urls: list[str] = []
  793. async def fake_health(url, timeout=2.0):
  794. seen_urls.append(url)
  795. return {"reachable": True, "version": "02.06.00.51"}
  796. # DB has use_slicer_api=true but NO bambu_studio_api_url row, simulating
  797. # a user who set the URL via the BAMBU_STUDIO_API_URL env var.
  798. session_ctx = self._make_settings_session({"use_slicer_api": "true", "preferred_slicer": "bambu_studio"})
  799. with (
  800. patch("backend.app.api.routes.support.async_session", session_ctx),
  801. patch("backend.app.api.routes.support._fetch_slicer_health", side_effect=fake_health),
  802. patch("backend.app.api.routes.support.settings") as mock_app_settings,
  803. ):
  804. # Pydantic-settings would normally do this for us when reading the
  805. # env var — we mock the resolved value directly.
  806. mock_app_settings.bambu_studio_api_url = "http://my-sidecar:3001"
  807. mock_app_settings.slicer_api_url = "http://localhost:3003"
  808. info = await _collect_slicer_api_info()
  809. # The env-var URL was the one actually pinged.
  810. assert "http://my-sidecar:3001" in seen_urls
  811. # And the source-tracking field shows we fell back from the DB to env.
  812. assert info["bambu_studio_url_set_in_db"] is False
  813. assert info["bambu_studio_url_source"] == "env_or_default"
  814. assert info["bambu_studio_reachable"] is True
  815. assert info["bambu_studio_version"] == "02.06.00.51"
  816. @pytest.mark.asyncio
  817. async def test_reachability_uses_unredacted_url(self):
  818. """Regression: the collector previously pinged the literal '[REDACTED]'
  819. from the already-sanitized info["settings"] dict and always returned
  820. False. The collector must read the un-redacted URL fresh from the DB.
  821. """
  822. from backend.app.api.routes.support import _collect_slicer_api_info
  823. seen_urls: list[str] = []
  824. async def fake_health(url, timeout=2.0):
  825. seen_urls.append(url)
  826. return {"reachable": True, "version": "unknown"}
  827. session_ctx = self._make_settings_session(
  828. {
  829. "use_slicer_api": "true",
  830. "bambu_studio_api_url": "http://real-bs-host:3001",
  831. "orcaslicer_api_url": "http://real-orca-host:3003",
  832. }
  833. )
  834. with (
  835. patch("backend.app.api.routes.support.async_session", session_ctx),
  836. patch("backend.app.api.routes.support._fetch_slicer_health", side_effect=fake_health),
  837. ):
  838. await _collect_slicer_api_info()
  839. assert "http://real-bs-host:3001" in seen_urls
  840. assert "http://real-orca-host:3003" in seen_urls
  841. assert "[REDACTED]" not in seen_urls
  842. class TestCollectAuthInfo:
  843. """Tests for the OIDC / 2FA / API-key / group bundle block."""
  844. @pytest.mark.asyncio
  845. async def test_empty_database_returns_zero_counts_and_empty_list(self):
  846. from backend.app.api.routes.support import _collect_auth_info
  847. def make_count(value):
  848. r = MagicMock()
  849. r.scalar.return_value = value
  850. r.scalar_one_or_none.return_value = None
  851. r.scalars.return_value.all.return_value = []
  852. r.all.return_value = []
  853. return r
  854. async def fake_execute(stmt, *_a, **_kw):
  855. return make_count(0)
  856. db = AsyncMock()
  857. db.execute = fake_execute
  858. info = await _collect_auth_info(db)
  859. assert info["oidc_providers"] == []
  860. assert info["users_with_totp"] == 0
  861. assert info["email_otp_codes_pending"] == 0
  862. assert info["api_keys_total"] == 0
  863. assert info["api_keys_enabled"] == 0
  864. assert info["api_keys_expired"] == 0
  865. assert info["long_lived_tokens_total"] == 0
  866. assert info["long_lived_tokens_active"] == 0
  867. assert info["groups_system"] == 0
  868. assert info["groups_custom"] == 0
  869. @pytest.mark.asyncio
  870. async def test_oidc_provider_names_exported_in_cleartext(self):
  871. """Provider names are login-button labels — public, not a secret. Triage
  872. for SSO bugs is significantly easier when the provider is identified."""
  873. from backend.app.api.routes.support import _collect_auth_info
  874. provider = MagicMock()
  875. provider.id = 1
  876. provider.name = "PocketID"
  877. provider.is_enabled = True
  878. provider.scopes = "openid email profile"
  879. provider.email_claim = "email"
  880. provider.require_email_verified = True
  881. provider.auto_create_users = False
  882. provider.auto_link_existing_accounts = False
  883. provider.default_group_id = None
  884. provider.icon_url = None
  885. def make_result(rows=None, count=0):
  886. r = MagicMock()
  887. r.scalar.return_value = count
  888. r.scalar_one_or_none.return_value = None
  889. r.scalars.return_value.all.return_value = rows or []
  890. r.all.return_value = []
  891. return r
  892. async def fake_execute(stmt, *_a, **_kw):
  893. sql = str(stmt).lower()
  894. if "oidc_providers" in sql and "user_oidc_link" not in sql:
  895. return make_result([provider])
  896. return make_result(count=0)
  897. db = AsyncMock()
  898. db.execute = fake_execute
  899. info = await _collect_auth_info(db)
  900. assert len(info["oidc_providers"]) == 1
  901. oidc = info["oidc_providers"][0]
  902. assert oidc["name"] == "PocketID"
  903. # No secrets leak through — these fields don't exist on the dict.
  904. assert "client_id" not in oidc
  905. assert "client_secret" not in oidc
  906. assert "issuer_url" not in oidc
  907. class TestCollectGitHubBackupInfo:
  908. """Tests for the GitHub-backup provider/failure-count block."""
  909. @pytest.mark.asyncio
  910. async def test_aggregates_providers_and_recent_failures(self):
  911. from backend.app.api.routes.support import _collect_github_backup_info
  912. c1 = MagicMock(provider="github", last_backup_status="success", schedule_enabled=True)
  913. c2 = MagicMock(provider="github", last_backup_status="failed", schedule_enabled=False)
  914. c3 = MagicMock(provider="gitea", last_backup_status="failed", schedule_enabled=True)
  915. result = MagicMock()
  916. result.scalars.return_value.all.return_value = [c1, c2, c3]
  917. db = AsyncMock()
  918. db.execute = AsyncMock(return_value=result)
  919. info = await _collect_github_backup_info(db)
  920. assert info["configs_total"] == 3
  921. assert info["providers_used"] == {"github": 2, "gitea": 1}
  922. assert info["schedule_enabled_count"] == 2
  923. assert info["last_failure_count"] == 2
  924. class TestRedactRawPushStatus:
  925. """Tests for _redact_raw_push_status() — the bundle dump scrubber."""
  926. def test_drops_user_filename_and_cloud_ids(self):
  927. from backend.app.api.routes.support import _redact_raw_push_status
  928. raw = {
  929. "subtask_name": "private_model.gcode",
  930. "gcode_file": "Metadata/private.gcode",
  931. "subtask_id": "1234567890",
  932. "task_id": "9999",
  933. "project_id": "proj-abc",
  934. "design_id": "design-1",
  935. "profile_id": "p-1",
  936. "model_id": "m-1",
  937. "gcode_state": "RUNNING",
  938. "layer_num": 42, # control: non-sensitive sibling must survive
  939. }
  940. out = _redact_raw_push_status(raw)
  941. assert "subtask_name" not in out
  942. assert "gcode_file" not in out
  943. assert "subtask_id" not in out
  944. assert "task_id" not in out
  945. assert "project_id" not in out
  946. assert "design_id" not in out
  947. assert "profile_id" not in out
  948. assert "model_id" not in out
  949. assert "gcode_state" not in out
  950. assert out["layer_num"] == 42
  951. def test_redacts_net_info_ip_addresses(self):
  952. from backend.app.api.routes.support import _redact_raw_push_status
  953. raw = {
  954. "net": {
  955. "conf": 1,
  956. "info": [
  957. {"ip": "192.168.1.42", "mask": "255.255.255.0"},
  958. {"ip": "10.0.0.1", "mask": "255.0.0.0"},
  959. ],
  960. },
  961. }
  962. out = _redact_raw_push_status(raw)
  963. # LAN topology must be scrubbed (mirrors the #1429 VP fix).
  964. assert out["net"]["info"][0]["ip"] == "0.0.0.0" # nosec B104 - redaction sentinel, not a bind address
  965. assert out["net"]["info"][1]["ip"] == "0.0.0.0" # nosec B104 - redaction sentinel, not a bind address
  966. # Non-IP siblings inside the entry survive so the shape stays
  967. # diagnosable (interface count, mask presence, etc.).
  968. assert out["net"]["info"][0]["mask"] == "255.255.255.0"
  969. assert out["net"]["conf"] == 1
  970. def test_preserves_print_cfg_and_ams_payloads(self):
  971. """The point of bundling raw_data is keeping these — print.cfg is what
  972. unblocks per-model AMS Backup detection (deferred in 85fbd7fc).
  973. """
  974. from backend.app.api.routes.support import _redact_raw_push_status
  975. raw = {
  976. "print": {
  977. "cfg": 0x4000000, # bit-26 — the H2D AMS Backup bit
  978. "option": 12345,
  979. },
  980. "ams": {
  981. "ams": [
  982. {
  983. "id": "0",
  984. "humidity": "3",
  985. "tray": [
  986. {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
  987. ],
  988. }
  989. ]
  990. },
  991. "vt_tray": {"tray_info_idx": "GFA00", "tray_type": "PLA", "tray_color": "00FF00FF"},
  992. "vir_slot": [{"id": "0", "tray_type": "PLA"}],
  993. "mapping": [0, 1, 2, 3],
  994. "ams_extruder_map": {"0": 1},
  995. }
  996. out = _redact_raw_push_status(raw)
  997. assert out["print"]["cfg"] == 0x4000000
  998. assert out["print"]["option"] == 12345
  999. assert out["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
  1000. assert out["vt_tray"]["tray_info_idx"] == "GFA00"
  1001. assert out["vir_slot"][0]["tray_type"] == "PLA"
  1002. assert out["mapping"] == [0, 1, 2, 3]
  1003. assert out["ams_extruder_map"] == {"0": 1}
  1004. def test_does_not_mutate_input(self):
  1005. """Live state.raw_data must not be touched — the dispatcher reads it on
  1006. every tick, mutation would race the next push.
  1007. """
  1008. from backend.app.api.routes.support import _redact_raw_push_status
  1009. raw = {
  1010. "subtask_name": "secret.gcode",
  1011. "net": {"info": [{"ip": "192.168.1.5"}]},
  1012. "print": {"cfg": 1},
  1013. }
  1014. original_subtask = raw["subtask_name"]
  1015. original_ip = raw["net"]["info"][0]["ip"]
  1016. _redact_raw_push_status(raw)
  1017. assert raw["subtask_name"] == original_subtask
  1018. assert raw["net"]["info"][0]["ip"] == original_ip
  1019. def test_handles_non_dict_gracefully(self):
  1020. from backend.app.api.routes.support import _redact_raw_push_status
  1021. assert _redact_raw_push_status(None) == {} # type: ignore[arg-type]
  1022. assert _redact_raw_push_status([]) == {} # type: ignore[arg-type]
  1023. assert _redact_raw_push_status("") == {} # type: ignore[arg-type]
  1024. class TestSanitizePushStatusValues:
  1025. """The bundled push_status snapshot must stay parseable JSON.
  1026. Sanitization used to run over the *serialised* snapshot. The generic
  1027. Bambu-serial regex in ``log_reader`` (``0[0-3][A-Z0-9][A-Z0-9]{9,13}``)
  1028. matches the decimal expansion of a float as readily as a serial, so an AMS
  1029. ``k`` flow factor came out as ``0.[SERIAL]`` and the whole file stopped
  1030. parsing — found in a real bundle while diagnosing #2702, which is exactly
  1031. the case the snapshot was added to serve.
  1032. """
  1033. def test_float_that_matches_the_serial_regex_survives(self):
  1034. """The observed reproducer, verbatim."""
  1035. import json
  1036. from backend.app.api.routes.support import _sanitize_push_status_values
  1037. raw = {"ams": [{"tray": [{"k": 0.0199999995529652}]}]}
  1038. out = _sanitize_push_status_values(raw, {})
  1039. assert json.loads(json.dumps(out)) == raw
  1040. def test_output_always_parses(self):
  1041. """Whatever it does to values, the result must be valid JSON."""
  1042. import json
  1043. from backend.app.api.routes.support import _sanitize_push_status_values
  1044. raw = {
  1045. "k_values": [0.0199999995529652, 0.019999999552965164, 0.02],
  1046. "home_flag": 7554487,
  1047. "sdcard": True,
  1048. "resolution": "",
  1049. "nozzle": None,
  1050. }
  1051. json.loads(json.dumps(_sanitize_push_status_values(raw, {})))
  1052. def test_still_redacts_strings(self):
  1053. """The point of the pass is not lost — string values are sanitized."""
  1054. from backend.app.api.routes.support import _sanitize_push_status_values
  1055. raw = {"tag_uid": "0123456789ABCDEF", "name": "Martin's P1S", "ip": "192.168.1.50"}
  1056. out = _sanitize_push_status_values(raw, {"Martin's P1S": "[PRINTER]"})
  1057. assert out["tag_uid"] == "[SERIAL]"
  1058. assert out["name"] == "[PRINTER]"
  1059. assert out["ip"] == "[IP]"
  1060. def test_walks_nested_containers(self):
  1061. from backend.app.api.routes.support import _sanitize_push_status_values
  1062. raw = {"ams": [{"tray": [{"tray_uuid": "0123456789ABCDEF"}]}]}
  1063. out = _sanitize_push_status_values(raw, {})
  1064. assert out["ams"][0]["tray"][0]["tray_uuid"] == "[SERIAL]"
  1065. def test_keys_are_left_alone(self):
  1066. """Keys are structural — renaming one would break the schema."""
  1067. from backend.app.api.routes.support import _sanitize_push_status_values
  1068. raw = {"0123456789ABCDEF": 1}
  1069. assert list(_sanitize_push_status_values(raw, {})) == ["0123456789ABCDEF"]
  1070. def test_non_json_scalars_are_sanitized_not_smuggled(self):
  1071. """``json.dumps(default=str)`` runs after this pass, so do it here."""
  1072. from datetime import datetime, timezone
  1073. from backend.app.api.routes.support import _sanitize_push_status_values
  1074. raw = {"seen_at": datetime(2026, 7, 29, 23, 12, 40, tzinfo=timezone.utc), "who": object()}
  1075. out = _sanitize_push_status_values(raw, {"2026-07-29": "[WHEN]"})
  1076. assert out["seen_at"].startswith("[WHEN]")
  1077. assert isinstance(out["who"], str)
  1078. def test_bools_stay_bools(self):
  1079. """`isinstance(True, int)` — a bool must not fall through to str()."""
  1080. from backend.app.api.routes.support import _sanitize_push_status_values
  1081. out = _sanitize_push_status_values({"sdcard": True, "force_upgrade": False}, {})
  1082. assert out["sdcard"] is True
  1083. assert out["force_upgrade"] is False
  1084. def test_the_full_bundle_chain_on_a_real_p1s_payload(self):
  1085. """The route's transform, end to end, on the shape from the #2702 bundle.
  1086. `_redact_raw_push_status` then `_sanitize_push_status_values` then
  1087. `json.dumps(default=str)` — the composition the bundle writer applies.
  1088. The bundle that exposed this had five `k` values corrupted, so the
  1089. snapshot could not be read at all; the field the report was about
  1090. (`total_layer_num`) was sitting in it, intact and unreachable.
  1091. """
  1092. import json
  1093. from backend.app.api.routes.support import (
  1094. _redact_raw_push_status,
  1095. _sanitize_push_status_values,
  1096. )
  1097. raw = {
  1098. "gcode_file": "AMS_Filament_Clip_3MF.3mf",
  1099. "layer_num": 2,
  1100. "total_layer_num": 33,
  1101. "home_flag": 7554487,
  1102. "sdcard": True,
  1103. "net": {"info": [{"ip": "192.168.1.50", "mask": 0}]},
  1104. "ams": {
  1105. "ams": [
  1106. {
  1107. "id": "0",
  1108. "humidity": "5",
  1109. "tray": [
  1110. {"id": "0", "k": 0.0199999995529652, "tag_uid": "0123456789ABCDEF"},
  1111. {"id": "1", "k": 0.0209999997168779, "tag_uid": "44F782D000000100"},
  1112. ],
  1113. }
  1114. ]
  1115. },
  1116. }
  1117. snapshot = {
  1118. "model": "P1S",
  1119. "firmware_version": "01.10.00.00",
  1120. "raw_data": _redact_raw_push_status(raw),
  1121. }
  1122. text = json.dumps(_sanitize_push_status_values(snapshot, {}), indent=2, default=str)
  1123. parsed = json.loads(text) # used to raise "Expecting ',' delimiter"
  1124. trays = parsed["raw_data"]["ams"]["ams"][0]["tray"]
  1125. assert [t["k"] for t in trays] == [0.0199999995529652, 0.0209999997168779]
  1126. assert parsed["raw_data"]["total_layer_num"] == 33
  1127. # Redaction still did its job on both fronts.
  1128. assert "gcode_file" not in parsed["raw_data"]
  1129. assert trays[0]["tag_uid"] == "[SERIAL]"
  1130. # The structural pass replaces the printer's LAN address with the
  1131. # sentinel 0.0.0.0, which is itself an IPv4 literal, so the value pass
  1132. # then masks it to [IP]. Harmless — the real address is already gone —
  1133. # and matches what shipped in the bundle behind #2702.
  1134. assert parsed["raw_data"]["net"]["info"][0]["ip"] == "[IP]"
  1135. def test_does_not_mutate_the_live_snapshot(self):
  1136. """`state.raw_data` is read by the dispatcher on every tick.
  1137. The bundle writer passes a redacted copy, but a walker that mutated in
  1138. place would still be one refactor away from redacting the live state.
  1139. """
  1140. import copy
  1141. from backend.app.api.routes.support import _sanitize_push_status_values
  1142. raw = {"tag_uid": "0123456789ABCDEF", "ams": [{"tray": [{"k": 0.02, "n": "0123456789ABCDEF"}]}]}
  1143. before = copy.deepcopy(raw)
  1144. out = _sanitize_push_status_values(raw, {})
  1145. assert raw == before, "input was mutated"
  1146. assert out["tag_uid"] == "[SERIAL]" # and the copy really was redacted
  1147. class TestProcessInfo:
  1148. """Bambuddy's own footprint in the bundle (#2734).
  1149. Bundles carried nothing about the process itself, so "memory climbs over
  1150. days until the OOM killer fires" could not be triaged from a bundle — the
  1151. reporter had to run shell commands by hand, and the numbers that would have
  1152. named the mechanism were unrecoverable afterwards.
  1153. """
  1154. def test_reports_the_figures_that_separate_the_mechanisms(self):
  1155. """RSS vs VMS, threads and children distinguish a heap that is growing
  1156. from address space, a thread leak, and a child-process leak."""
  1157. from backend.app.api.routes.support import _collect_process_info
  1158. info = _collect_process_info()
  1159. assert info["available"] is True
  1160. for key in ("rss_bytes", "vms_bytes", "num_threads", "children_total"):
  1161. assert isinstance(info[key], int), key
  1162. def test_children_are_named_but_never_quoted(self):
  1163. """An ffmpeg argv carries the camera URL, and with it its password. The
  1164. count per executable is what identifies a leak; the arguments are not
  1165. needed and must not travel."""
  1166. from backend.app.api.routes.support import _collect_process_info
  1167. info = _collect_process_info()
  1168. for name in info.get("children_by_name", {}):
  1169. assert " " not in name, f"looks like a command line, not a name: {name!r}"
  1170. assert "://" not in name
  1171. def test_heap_census_is_skipped_on_a_large_process(self):
  1172. """gc.get_objects() materialises every tracked object, so the census
  1173. costs most on the process that can least afford it. A bundle generated
  1174. to diagnose runaway memory must not be the allocation that tips the
  1175. host over."""
  1176. from unittest.mock import MagicMock, patch
  1177. import backend.app.api.routes.support as support_module
  1178. fake = MagicMock()
  1179. fake.memory_info.return_value = MagicMock(rss=8 * 1024**3, vms=12 * 1024**3)
  1180. fake.num_threads.return_value = 40
  1181. fake.create_time.return_value = 0.0
  1182. fake.open_files.return_value = []
  1183. fake.net_connections.return_value = []
  1184. fake.children.return_value = []
  1185. with patch("psutil.Process", return_value=fake):
  1186. info = support_module._collect_process_info()
  1187. assert "gc_top_types" not in info
  1188. assert "skipped" in info["gc_census"]
  1189. # The discriminating numbers still come through — those are the point.
  1190. assert info["rss_bytes"] == 8 * 1024**3
  1191. assert info["num_threads"] == 40
  1192. def test_heap_census_runs_on_a_normal_process(self):
  1193. from backend.app.api.routes.support import _collect_process_info
  1194. info = _collect_process_info()
  1195. assert info["gc_tracked_objects"] > 0
  1196. assert len(info["gc_top_types"]) <= 15
  1197. def test_survives_a_hostile_psutil(self):
  1198. """psutil raises on hardened kernels and in restricted containers. A
  1199. support bundle must still be produced when it does — the bundle is how
  1200. someone reports the problem in the first place."""
  1201. from unittest.mock import patch
  1202. import backend.app.api.routes.support as support_module
  1203. with patch("psutil.Process", side_effect=RuntimeError("no /proc for you")):
  1204. info = support_module._collect_process_info()
  1205. assert info == {"available": False}
  1206. def test_partial_failures_do_not_lose_the_rest(self):
  1207. """One inaccessible metric must not cost the others."""
  1208. from unittest.mock import MagicMock, patch
  1209. import backend.app.api.routes.support as support_module
  1210. fake = MagicMock()
  1211. fake.memory_info.return_value = MagicMock(rss=100, vms=200)
  1212. fake.num_threads.side_effect = PermissionError("denied")
  1213. fake.create_time.return_value = 0.0
  1214. fake.open_files.side_effect = PermissionError("denied")
  1215. fake.net_connections.side_effect = PermissionError("denied")
  1216. fake.children.return_value = []
  1217. with patch("psutil.Process", return_value=fake):
  1218. info = support_module._collect_process_info()
  1219. assert info["rss_bytes"] == 100
  1220. assert "num_threads" not in info
  1221. assert info["children_total"] == 0