test_printer_diagnostic.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. """Unit tests for the connection diagnostic.
  2. Pins the pass / fail / warn / skip contract of each check. Those statuses
  3. drive the localized fix text the user sees when a printer won't connect,
  4. so a status flip is a user-facing regression — each one is asserted here.
  5. """
  6. import ssl
  7. import types
  8. from contextlib import ExitStack
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. from backend.app.services.printer_diagnostic import (
  11. _check_ftps_tls,
  12. _same_subnet,
  13. run_connection_diagnostic,
  14. )
  15. MOD = "backend.app.services.printer_diagnostic"
  16. def _statuses(result):
  17. """Map of check id -> status for concise assertions."""
  18. return {c.id: c.status for c in result.checks}
  19. def _port_probe(overrides=None):
  20. """Sync side_effect for _check_port. Defaults: every port reachable.
  21. 990 is absent: the FTPS check runs a real TLS handshake through
  22. ``_check_ftps_tls`` rather than a bare TCP probe, and ``_Env(ftps=...)``
  23. drives it.
  24. """
  25. reachable = {8883: True, 322: True, 6000: True}
  26. reachable.update(overrides or {})
  27. def _probe(ip, port, timeout=3.0):
  28. return reachable[port]
  29. return _probe
  30. def _state(*, connected=True, developer_mode=True, store_to_sdcard=True):
  31. return types.SimpleNamespace(
  32. connected=connected,
  33. developer_mode=developer_mode,
  34. store_to_sdcard=store_to_sdcard,
  35. )
  36. class _Env:
  37. """Patches the diagnostic's network/printer helpers for one run."""
  38. def __init__(
  39. self,
  40. *,
  41. ports=None,
  42. ftps="ok",
  43. in_docker=True,
  44. network_mode="host",
  45. host_ip="192.168.1.5",
  46. state=None,
  47. test_connection_success=True,
  48. report_messages_since_connect: int | None = 5,
  49. connect_error: str | None = None,
  50. ):
  51. self.ports = ports or _port_probe()
  52. # What the FTPS probe reports: "ok", "closed" or "no_tls" (#2780).
  53. self.ftps = ftps
  54. self.in_docker = in_docker
  55. self.network_mode = network_mode
  56. self.host_ip = host_ip
  57. self.state = state
  58. self.test_connection_success = test_connection_success
  59. # ``None`` means get_client returns None (e.g. pre-add flow); an int
  60. # means there's a client with that counter value.
  61. self.report_messages_since_connect = report_messages_since_connect
  62. # CONNACK-refusal slug the live client reports, or None when the last
  63. # connection attempt was never refused (#2698).
  64. self.connect_error = connect_error
  65. self._stack = ExitStack()
  66. def __enter__(self):
  67. manager = MagicMock()
  68. manager.get_status.return_value = self.state
  69. manager.test_connection = AsyncMock(
  70. return_value={
  71. "success": self.test_connection_success,
  72. "reason": None if self.test_connection_success else self.connect_error,
  73. }
  74. )
  75. if self.report_messages_since_connect is None:
  76. manager.get_client.return_value = None
  77. else:
  78. client = MagicMock()
  79. client.report_messages_since_connect = self.report_messages_since_connect
  80. client.last_connect_error = self.connect_error
  81. manager.get_client.return_value = client
  82. self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
  83. self._stack.enter_context(patch(f"{MOD}._check_ftps_tls", new_callable=AsyncMock, return_value=self.ftps))
  84. self._stack.enter_context(patch(f"{MOD}.is_running_in_docker", return_value=self.in_docker))
  85. self._stack.enter_context(patch(f"{MOD}._detect_docker_network_mode", return_value=self.network_mode))
  86. self._stack.enter_context(patch(f"{MOD}._get_host_ip", return_value=self.host_ip))
  87. self._stack.enter_context(patch(f"{MOD}.printer_manager", manager))
  88. return self
  89. def __exit__(self, *exc):
  90. self._stack.close()
  91. return False
  92. def _printer(ip="192.168.1.50", model=None):
  93. return types.SimpleNamespace(id=1, ip_address=ip, model=model)
  94. class TestSameSubnet:
  95. def test_same_24(self):
  96. assert _same_subnet("192.168.1.10", "192.168.1.200") is True
  97. def test_different_24(self):
  98. assert _same_subnet("192.168.1.10", "192.168.2.10") is False
  99. def test_hostname_undeterminable(self):
  100. assert _same_subnet("printer.local", "192.168.1.10") is None
  101. def test_ipv6_undeterminable(self):
  102. assert _same_subnet("fe80::1", "192.168.1.10") is None
  103. class TestExistingPrinter:
  104. async def test_all_healthy(self):
  105. with _Env(
  106. state=_state(connected=True, developer_mode=True, store_to_sdcard=True),
  107. report_messages_since_connect=42,
  108. ):
  109. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  110. s = _statuses(result)
  111. assert result.overall == "ok"
  112. assert s == {
  113. "port_mqtt": "pass",
  114. "port_ftps": "pass",
  115. "port_rtsps": "pass",
  116. "network_mode": "pass",
  117. "subnet": "pass",
  118. "external_storage": "pass",
  119. "mqtt_auth": "pass",
  120. "developer_mode": "pass",
  121. "printer_publishing": "pass",
  122. }
  123. async def test_mqtt_port_unreachable_is_a_problem(self):
  124. with _Env(ports=_port_probe({8883: False}), state=_state()):
  125. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  126. s = _statuses(result)
  127. assert result.overall == "problems"
  128. assert s["port_mqtt"] == "fail"
  129. # Auth can't be judged when the broker port itself is closed.
  130. assert s["mqtt_auth"] == "skip"
  131. async def test_ftps_and_rtsps_only_warn(self):
  132. with _Env(ports=_port_probe({322: False}), ftps="closed", state=_state()):
  133. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  134. s = _statuses(result)
  135. # No critical failure -> warnings, not problems.
  136. assert result.overall == "warnings"
  137. assert s["port_ftps"] == "warn"
  138. assert s["port_rtsps"] == "warn"
  139. # Nothing was listening, so the message stays the generic "unblock the
  140. # port" one — no reason variant.
  141. ftps_check = next(c for c in result.checks if c.id == "port_ftps")
  142. assert ftps_check.params == {}
  143. async def test_open_port_that_cannot_negotiate_tls_says_so(self):
  144. """An open 990 that fails the handshake must not read as healthy.
  145. #2780's reporter saw port 990 green while every 3MF download died in
  146. the TLS handshake, so the archives arrived empty with nothing on
  147. screen to explain it. The reason variant selects a message that names
  148. a printer restart instead of sending the user to their firewall.
  149. """
  150. with _Env(ftps="no_tls", state=_state()):
  151. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P2S"))
  152. assert _statuses(result)["port_ftps"] == "warn"
  153. ftps_check = next(c for c in result.checks if c.id == "port_ftps")
  154. assert ftps_check.params == {"reason": "no_tls"}
  155. assert result.overall == "warnings"
  156. async def test_a1_mini_uses_chamber_image_camera_port(self):
  157. # A1/P1-family printers use the chamber-image camera protocol on 6000,
  158. # not RTSPS on 322. A closed 322 must not create a false camera warning.
  159. with _Env(ports=_port_probe({322: False, 6000: True}), state=_state()):
  160. result = await run_connection_diagnostic(
  161. "192.168.1.50",
  162. printer=_printer(model="A1 Mini"),
  163. )
  164. assert _statuses(result)["port_rtsps"] == "pass"
  165. camera_check = next(c for c in result.checks if c.id == "port_rtsps")
  166. assert camera_check.params == {"port": 6000, "protocol": "Chamber Image"}
  167. async def test_rtsp_models_still_probe_rtsps_port(self):
  168. with _Env(ports=_port_probe({322: False, 6000: True}), state=_state()):
  169. result = await run_connection_diagnostic(
  170. "192.168.1.50",
  171. printer=_printer(model="X1C"),
  172. )
  173. assert _statuses(result)["port_rtsps"] == "warn"
  174. camera_check = next(c for c in result.checks if c.id == "port_rtsps")
  175. assert camera_check.params == {"port": 322, "protocol": "RTSPS"}
  176. async def test_developer_mode_off_is_a_problem(self):
  177. with _Env(state=_state(connected=True, developer_mode=False)):
  178. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  179. s = _statuses(result)
  180. assert s["developer_mode"] == "fail"
  181. assert result.overall == "problems"
  182. async def test_developer_mode_skipped_when_disconnected(self):
  183. # No live MQTT connection -> developer_mode can't be read.
  184. with _Env(state=_state(connected=False)):
  185. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  186. s = _statuses(result)
  187. assert s["developer_mode"] == "skip"
  188. # Reachable port but no connection -> credential failure class.
  189. assert s["mqtt_auth"] == "fail"
  190. # Can't observe report messages without a connection.
  191. assert s["printer_publishing"] == "skip"
  192. async def test_bridge_mode_warns_and_skips_subnet(self):
  193. with _Env(network_mode="bridge", state=_state()):
  194. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  195. s = _statuses(result)
  196. assert s["network_mode"] == "warn"
  197. # Container IP isn't the host IP in bridge mode -> subnet check is meaningless.
  198. assert s["subnet"] == "skip"
  199. async def test_network_mode_skipped_outside_docker(self):
  200. with _Env(in_docker=False, state=_state()):
  201. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  202. assert _statuses(result)["network_mode"] == "skip"
  203. async def test_different_subnet_warns(self):
  204. with _Env(host_ip="10.0.0.5", state=_state()):
  205. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  206. assert _statuses(result)["subnet"] == "warn"
  207. async def test_printer_publishing_passes_when_reports_seen(self):
  208. # Counter > 0 means the printer is publishing on the report topic.
  209. with _Env(state=_state(), report_messages_since_connect=1):
  210. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  211. assert _statuses(result)["printer_publishing"] == "pass"
  212. async def test_printer_publishing_fails_when_zero_reports_after_wait(self):
  213. # Counter stays at 0 across the wait window — printer never published.
  214. # Tiny wait_for_publish_seconds keeps the test sub-second.
  215. with _Env(state=_state(), report_messages_since_connect=0):
  216. result = await run_connection_diagnostic(
  217. "192.168.1.50",
  218. printer=_printer(),
  219. wait_for_publish_seconds=0.05,
  220. )
  221. s = _statuses(result)
  222. assert s["printer_publishing"] == "fail"
  223. # Overall escalates because fail is present.
  224. assert result.overall == "problems"
  225. # The check exposes the wait budget so the UI can render a countdown.
  226. params = next(c.params for c in result.checks if c.id == "printer_publishing")
  227. assert params == {"max_wait_seconds": 0.05}
  228. async def test_printer_publishing_skips_when_disconnected(self):
  229. # No live MQTT connection -> can't observe report messages.
  230. with _Env(state=_state(connected=False), report_messages_since_connect=0):
  231. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  232. assert _statuses(result)["printer_publishing"] == "skip"
  233. async def test_printer_publishing_skips_when_no_client(self):
  234. # State says connected but printer_manager has no client object
  235. # (race between client teardown and a fresh diagnostic request).
  236. with _Env(state=_state(), report_messages_since_connect=None):
  237. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  238. assert _statuses(result)["printer_publishing"] == "skip"
  239. async def test_printer_publishing_no_wait_returns_instantly_on_zero(self):
  240. # Default wait is 0 — instant pass/fail without polling. Used by the
  241. # support-package code path so bundling stays fast.
  242. with _Env(state=_state(), report_messages_since_connect=0):
  243. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  244. s = _statuses(result)
  245. assert s["printer_publishing"] == "fail"
  246. params = next(c.params for c in result.checks if c.id == "printer_publishing")
  247. # No wait -> no max_wait_seconds param surfaced to the UI.
  248. assert params == {}
  249. class TestAuthRejectedReason:
  250. """#2698: "not connected" and "credentials refused" are different answers.
  251. `state.connected == False` only says we have no session — the printer may
  252. be rebooting, at its connection limit, or refusing the access code. When
  253. the printer actually sent a CONNACK refusal the client records it, and the
  254. check surfaces it as a `params.reason` variant so the UI can name the cause
  255. instead of making the user guess. Without a recorded refusal the params
  256. stay empty and the generic text is used.
  257. """
  258. def _params(self, result):
  259. return next(c.params for c in result.checks if c.id == "mqtt_auth")
  260. async def test_recorded_refusal_surfaces_reason(self):
  261. with _Env(state=_state(connected=False), connect_error="auth_rejected"):
  262. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  263. assert _statuses(result)["mqtt_auth"] == "fail"
  264. assert self._params(result) == {"reason": "auth_rejected"}
  265. async def test_disconnected_without_refusal_stays_generic(self):
  266. with _Env(state=_state(connected=False)):
  267. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  268. assert _statuses(result)["mqtt_auth"] == "fail"
  269. assert self._params(result) == {}
  270. async def test_unknown_slug_falls_back_to_generic(self):
  271. # `refused` has no dedicated message — degrade to the plain fail text
  272. # rather than asking the frontend for a key that doesn't exist.
  273. with _Env(state=_state(connected=False), connect_error="refused"):
  274. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  275. assert self._params(result) == {}
  276. async def test_connected_printer_carries_no_reason(self):
  277. with _Env(state=_state(connected=True), connect_error="auth_rejected"):
  278. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  279. assert _statuses(result)["mqtt_auth"] == "pass"
  280. assert self._params(result) == {}
  281. async def test_pre_add_probe_surfaces_reason(self):
  282. with _Env(test_connection_success=False, connect_error="auth_rejected"):
  283. result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="wrong")
  284. assert _statuses(result)["mqtt_auth"] == "fail"
  285. assert self._params(result) == {"reason": "auth_rejected"}
  286. class TestPreAddFlow:
  287. async def test_bad_credentials_fail_mqtt_auth(self):
  288. with _Env(test_connection_success=False):
  289. result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="wrong")
  290. s = _statuses(result)
  291. assert s["mqtt_auth"] == "fail"
  292. # No saved printer -> developer mode can't be read.
  293. assert s["developer_mode"] == "skip"
  294. async def test_good_credentials_pass_mqtt_auth(self):
  295. with _Env(test_connection_success=True):
  296. result = await run_connection_diagnostic("192.168.1.50", serial_number="01P", access_code="right")
  297. assert _statuses(result)["mqtt_auth"] == "pass"
  298. async def test_no_credentials_skips_mqtt_auth(self):
  299. with _Env():
  300. result = await run_connection_diagnostic("192.168.1.50")
  301. assert _statuses(result)["mqtt_auth"] == "skip"
  302. class TestExternalStorageCheck:
  303. """Install step 4 — "Store sent files on external storage".
  304. Detected via ``state.store_to_sdcard`` (parsed from MQTT push_status
  305. ``home_flag`` bit 11). Only catches the printer-side variant of the
  306. setting on newer firmware (P2S 01.02 / Studio 2.6+) — the older
  307. slicer-side variant is undetectable from outside the slicer and is
  308. covered separately by the no-3MF archive-fallback banner.
  309. """
  310. async def test_passes_when_store_to_sdcard_true(self):
  311. with _Env(state=_state(store_to_sdcard=True)):
  312. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  313. assert _statuses(result)["external_storage"] == "pass"
  314. async def test_fails_when_store_to_sdcard_false(self):
  315. # Bit 11 reported as 0 -> printer-side toggle is off. Overall
  316. # escalates to "problems" because a fail is present.
  317. with _Env(state=_state(store_to_sdcard=False)):
  318. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  319. assert _statuses(result)["external_storage"] == "fail"
  320. assert result.overall == "problems"
  321. async def test_skips_when_disconnected(self):
  322. # State exists (we have a saved printer) but the MQTT connection
  323. # dropped, so the latest store_to_sdcard value can't be trusted.
  324. with _Env(state=_state(connected=False, store_to_sdcard=True)):
  325. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  326. assert _statuses(result)["external_storage"] == "skip"
  327. async def test_skips_pre_add_flow(self):
  328. # No saved printer -> no state -> nothing to read. The check has
  329. # to skip; pre-add can't probe this without a live MQTT session.
  330. with _Env():
  331. result = await run_connection_diagnostic(
  332. "192.168.1.50",
  333. serial_number="01P",
  334. access_code="probe-code",
  335. )
  336. assert _statuses(result)["external_storage"] == "skip"
  337. async def test_skips_when_field_missing(self):
  338. # State exists and is connected but store_to_sdcard was never
  339. # populated (firmware that doesn't push home_flag). Skip rather
  340. # than fabricate a False from a missing field.
  341. bare = types.SimpleNamespace(connected=True, developer_mode=True)
  342. with _Env(state=bare):
  343. result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
  344. assert _statuses(result)["external_storage"] == "skip"
  345. async def test_skips_on_a1_no_external_storage_slot(self):
  346. # Regression for #1703: A1 and A1 Mini ship without a MicroSD slot
  347. # at all, so home_flag bit 11 is never set and a naive read would
  348. # report `fail` for every A1-series user. The model-aware skip
  349. # branch suppresses that — and the overall result must NOT escalate
  350. # to "problems" purely because of this check.
  351. with _Env(state=_state(store_to_sdcard=False)):
  352. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="A1"))
  353. assert _statuses(result)["external_storage"] == "skip"
  354. assert result.overall == "ok"
  355. async def test_skips_on_a1_mini_no_external_storage_slot(self):
  356. with _Env(state=_state(store_to_sdcard=False)):
  357. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="A1 Mini"))
  358. assert _statuses(result)["external_storage"] == "skip"
  359. async def test_still_fails_on_x1c_when_toggle_off(self):
  360. # Sanity: the model-aware skip MUST NOT silently let X1C-class
  361. # printers off the hook. The store_to_sdcard=False path is the
  362. # one real bit of value this check provides for those models.
  363. with _Env(state=_state(store_to_sdcard=False)):
  364. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="X1C"))
  365. assert _statuses(result)["external_storage"] == "fail"
  366. async def test_skips_on_p1s_no_reachable_toggle(self):
  367. # #2524: P1S HAS a MicroSD slot (so has_external_storage is True and
  368. # the check proceeds), but current P1 firmware never publishes the
  369. # capability that renders the toggle in Bambu Studio and the P1S has
  370. # no screen — store_to_sdcard is stuck False with no way to fix it.
  371. # Report an informational skip (with a reason the UI explains), not a
  372. # permanently-unresolvable fail; overall must not escalate.
  373. with _Env(state=_state(store_to_sdcard=False)):
  374. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P1S"))
  375. check = next(c for c in result.checks if c.id == "external_storage")
  376. assert check.status == "skip"
  377. assert check.params == {"reason": "unsupported_model"}
  378. assert result.overall == "ok"
  379. async def test_skips_on_p1p_no_reachable_toggle(self):
  380. with _Env(state=_state(store_to_sdcard=False)):
  381. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P1P"))
  382. check = next(c for c in result.checks if c.id == "external_storage")
  383. assert check.status == "skip"
  384. assert check.params == {"reason": "unsupported_model"}
  385. async def test_p1s_still_passes_when_store_to_sdcard_true(self):
  386. # If a P1S somehow reports the option ON, respect it — pass, don't
  387. # mask it as an unsupported-model skip.
  388. with _Env(state=_state(store_to_sdcard=True)):
  389. result = await run_connection_diagnostic("192.168.1.50", printer=_printer(model="P1S"))
  390. assert _statuses(result)["external_storage"] == "pass"
  391. class TestFtpsTlsProbe:
  392. """The FTPS probe must reach the handshake, not stop at the TCP accept.
  393. #2780: a printer whose file service stops answering with TLS still
  394. accepts the connection on 990, so the old bare TCP probe reported it
  395. green while every archive came back empty.
  396. """
  397. async def test_completed_handshake_is_ok(self):
  398. writer = MagicMock()
  399. writer.wait_closed = AsyncMock()
  400. with patch(f"{MOD}.asyncio.open_connection", new_callable=AsyncMock, return_value=(MagicMock(), writer)):
  401. assert await _check_ftps_tls("192.168.1.50", "X1C") == "ok"
  402. writer.close.assert_called_once()
  403. async def test_handshake_failure_on_an_open_port_is_no_tls(self):
  404. with patch(
  405. f"{MOD}.asyncio.open_connection",
  406. new_callable=AsyncMock,
  407. side_effect=ssl.SSLError("[SSL: WRONG_VERSION_NUMBER] wrong version number"),
  408. ):
  409. assert await _check_ftps_tls("192.168.1.50", "P2S") == "no_tls"
  410. async def test_refused_connection_is_closed(self):
  411. with patch(f"{MOD}.asyncio.open_connection", new_callable=AsyncMock, side_effect=ConnectionRefusedError):
  412. assert await _check_ftps_tls("192.168.1.50", "X1C") == "closed"
  413. async def test_timeout_is_closed_not_no_tls(self):
  414. # A printer that is switched off never gets far enough to say anything
  415. # about TLS — that has to stay the generic "port unreachable" advice.
  416. with patch(f"{MOD}.asyncio.open_connection", new_callable=AsyncMock, side_effect=TimeoutError):
  417. assert await _check_ftps_tls("192.168.1.50", "X1C") == "closed"
  418. async def test_probe_mirrors_the_model_tls_cap(self):
  419. """The probe must negotiate exactly what the FTP client negotiates.
  420. A P2S is pinned to TLS 1.2 by its ftp_profiles entry; probing it on a
  421. context that also offers 1.3 could pass where the real transfer fails
  422. (or the reverse), which is the class of false green this check exists
  423. to remove.
  424. """
  425. contexts = []
  426. async def _capture(host, port, ssl=None):
  427. contexts.append(ssl)
  428. writer = MagicMock()
  429. writer.wait_closed = AsyncMock()
  430. return MagicMock(), writer
  431. with patch(f"{MOD}.asyncio.open_connection", new=_capture):
  432. await _check_ftps_tls("192.168.1.50", "P2S")
  433. await _check_ftps_tls("192.168.1.50", "X1C")
  434. capped, uncapped = contexts
  435. assert capped.maximum_version == ssl.TLSVersion.TLSv1_2
  436. assert capped.minimum_version == ssl.TLSVersion.TLSv1_2
  437. assert uncapped.maximum_version != ssl.TLSVersion.TLSv1_2
  438. assert uncapped.minimum_version == ssl.TLSVersion.TLSv1_2