3131import time
3232import urllib .error
3333import urllib .request
34+ import warnings
3435from collections .abc import AsyncGenerator
3536from functools import partial
3637from pathlib import Path
3940import pytest
4041import requests
4142from testcontainers .core .container import DockerContainer
43+ from urllib3 .exceptions import InsecureRequestWarning
4244
4345# Add src to path for imports
4446sys .path .insert (0 , str (Path (__file__ ).parent .parent .parent / "src" ))
6769 set_default_backup_password ,
6870 stage_embedded_server_feature_flags_in_qcow2 ,
6971 stage_embedded_server_wheel_in_qcow2 ,
72+ stage_home_assistant_tls_in_qcow2 ,
7073 trigger_dev_addon_update ,
7174 wait_for_addon_ha_link_ready ,
7275 wait_for_addon_mcp_ready ,
@@ -228,6 +231,34 @@ def _log_readiness_timing(gate: str, elapsed_s: float, **extras: Any) -> None:
228231 _READINESS_TIMINGS .append ({"gate" : gate , "elapsed_s" : elapsed_s , ** extras })
229232
230233
234+ def _move_haos_tls_items_last (items : list [Any ]) -> None :
235+ """Make the one destructive Core-restart scope the final xdist work unit.
236+
237+ ``loadscope`` groups tests into scopes by module (or module::class). With
238+ xdist's default ``--loadscope-reorder`` the scopes are sorted by
239+ descending size with a stable sort, so the one-test TLS module moved to
240+ the end stays last among the one-test scopes; with ``--no-loadscope-reorder``
241+ plain collection order keeps it last outright. The FIFO workqueue then
242+ dispatches it as the final unit. The worker that receives it may still
243+ hold up to two queued ordinary tests (xdist tops nodes up at <=2 pending)
244+ but executes units in dispatch order, so Core is restarted in that
245+ worker's already-running VM only after its ordinary work has run.
246+ """
247+ ordinary = [item for item in items if "haos_tls" not in item .keywords ]
248+ tls = [item for item in items if "haos_tls" in item .keywords ]
249+ items [:] = [* ordinary , * tls ]
250+
251+
252+ def _apply_haos_tls_skip (item : Any , enabled : bool , skip_marker : Any ) -> None :
253+ """Skip a ``haos_tls`` item on every lane where the scenario is disabled.
254+
255+ Kept out-of-line so ``pytest_collection_modifyitems`` stays under the
256+ repo-wide C901 ceiling — inlining this branch trips it.
257+ """
258+ if "haos_tls" in item .keywords and not enabled :
259+ item .add_marker (skip_marker )
260+
261+
231262def pytest_collection_modifyitems (config , items ):
232263 """Enforce backend markers and auto-apply ``haos_only`` to its dir.
233264
@@ -249,6 +280,8 @@ def pytest_collection_modifyitems(config, items):
249280 exercised). Skipped on external mode and on testcontainer.
250281 - ``haos_stdio_only``: HAOS stdio mode only (``mcp_client`` launches the
251282 installed ``ha-mcp`` command and uses real stdio JSON-RPC framing).
283+ - ``haos_tls``: final HAOS-embedded scenario. It restarts Core with HTTPS,
284+ exercises the same VM, and restores HTTP before session teardown.
252285 """
253286 del config
254287 haos = is_haos_backend_selected ()
@@ -281,6 +314,9 @@ def pytest_collection_modifyitems(config, items):
281314 skip_haos_stdio_only = pytest .mark .skip (
282315 reason = "HAOS stdio mode required (set HAOS_TEST_MODE=stdio)"
283316 )
317+ skip_haos_tls = pytest .mark .skip (
318+ reason = "final Core TLS scenario runs only in the existing HAOS embedded worker"
319+ )
284320 skip_external_only = pytest .mark .skip (
285321 reason = "out-of-process server (stdio/inaddon/embedded); test needs an "
286322 "in-process server it can reconfigure via env/monkeypatch or reach an "
@@ -306,6 +342,12 @@ def pytest_collection_modifyitems(config, items):
306342 item .add_marker (skip_inaddon_only )
307343 if "haos_stdio_only" in keywords and not haos_stdio :
308344 item .add_marker (skip_haos_stdio_only )
345+ _apply_haos_tls_skip (item , haos_embedded , skip_haos_tls )
346+ # Deliberate: inserting the haos_tls dispatch above broke the old
347+ # ``elif`` chain into this ``if``. An item carrying several gate
348+ # markers now collects one skip marker per gate instead of the first
349+ # only — still one skipped item, same counts; only the reported
350+ # reason (first marker added) could differ.
309351 # ``external_only`` skips on any tier where the server is NOT in the
310352 # pytest process: the inaddon HAOS addon AND the embedded backend's
311353 # in-process MCP server (both #1527). The name is historical (from
@@ -327,7 +369,7 @@ def pytest_collection_modifyitems(config, items):
327369 # cannot observe pytest-process env changes, monkeypatches, or in-process
328370 # mocks. The same tests retain coverage on container/external HAOS lanes.
329371 #
330- elif "external_only" in keywords and (
372+ if "external_only" in keywords and (
331373 inaddon or embedded or haos_embedded or haos_stdio
332374 ):
333375 item .add_marker (skip_external_only )
@@ -347,6 +389,7 @@ def pytest_collection_modifyitems(config, items):
347389 # the sole thing exercising the in-process server).
348390 if "not_on_haos_embedded" in keywords and haos_embedded :
349391 item .add_marker (skip_not_on_haos_embedded )
392+ _move_haos_tls_items_last (items )
350393
351394
352395# Fail fast on a doomed run, on EVERY e2e lane (this conftest is shared by the
@@ -973,12 +1016,16 @@ def _embedded_mcp_result(resp: requests.Response) -> dict[str, Any] | None:
9731016 return parse_mcp_response (resp .headers .get ("Content-Type" , "" ), resp .content )
9741017
9751018
976- def _wait_for_embedded_webhook_ready (webhook_url : str , timeout : int ) -> bool :
1019+ def _wait_for_embedded_webhook_ready (
1020+ webhook_url : str , timeout : int , * , verify : bool = True
1021+ ) -> bool :
9771022 """Poll the embedded server's ingress webhook until MCP ``initialize`` works.
9781023
9791024 A valid JSON-RPC ``result`` means the in-process MCP server has installed
9801025 itself, started its worker thread, and registered the webhook. Returns False
9811026 on timeout so the caller can dump diagnostics and fail with context.
1027+ ``verify=False`` lets the TLS scenario poll the ``https://`` webhook while
1028+ Core runs its trial certificate.
9821029 """
9831030 payload = {
9841031 "jsonrpc" : "2.0" ,
@@ -997,9 +1044,19 @@ def _wait_for_embedded_webhook_ready(webhook_url: str, timeout: int) -> bool:
9971044 deadline = time .monotonic () + timeout
9981045 while time .monotonic () < deadline :
9991046 try :
1000- resp = requests .post (
1001- webhook_url , headers = headers , data = json .dumps (payload ), timeout = 30
1002- )
1047+ with warnings .catch_warnings ():
1048+ if not verify :
1049+ # This test deliberately uses Core's hostname-mismatched
1050+ # loopback certificate. Keep the suite's warnings-as-errors
1051+ # policy for every warning except this expected one.
1052+ warnings .simplefilter ("ignore" , InsecureRequestWarning )
1053+ resp = requests .post (
1054+ webhook_url ,
1055+ headers = headers ,
1056+ data = json .dumps (payload ),
1057+ timeout = 30 ,
1058+ verify = verify ,
1059+ )
10031060 if resp .status_code == 200 :
10041061 parsed = _embedded_mcp_result (resp )
10051062 if parsed is not None and "result" in parsed :
@@ -1842,6 +1899,22 @@ def _prepare_haos_image(
18421899 # below applies it via Docker layer cache (#1349 item 7).
18431900 if inaddon :
18441901 refresh_dev_addon_source_in_qcow2 (image_path )
1902+ if haos_embedded :
1903+ # Best-effort like the wheel staging above: only the final TLS scenario
1904+ # consumes this certificate, and it asserts on HAOS_TEST_TLS_CA_PATH —
1905+ # a staging failure should fail that one test, not abort the lane.
1906+ try :
1907+ certificate = stage_home_assistant_tls_in_qcow2 (image_path )
1908+ except RuntimeError :
1909+ logger .warning (
1910+ "HAOS Core TLS staging failed; the TLS scenario will report it" ,
1911+ exc_info = True ,
1912+ )
1913+ else :
1914+ # Do not alter process-wide trust during ordinary tests. The final
1915+ # TLS scenario trusts this cert only while reproducing the legacy
1916+ # mismatch.
1917+ os .environ ["HAOS_TEST_TLS_CA_PATH" ] = str (certificate )
18451918 return image_path
18461919
18471920
0 commit comments