span-panel-api 3.3.0__tar.gz → 3.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/CHANGELOG.md +21 -0
  2. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/PKG-INFO +1 -1
  3. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/pyproject.toml +1 -1
  4. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/__init__.py +6 -0
  5. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/_http.py +55 -3
  6. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/auth.py +2 -2
  7. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/exceptions.py +19 -0
  8. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/factory.py +15 -2
  9. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/client.py +57 -1
  10. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_plaintext_warning.py +41 -7
  11. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_public_api_unchanged.py +5 -0
  12. span_panel_api-3.4.0/tests/test_schema_fetch_transport_split.py +276 -0
  13. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/.gitignore +0 -0
  14. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/LICENSE +0 -0
  15. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/README.md +0 -0
  16. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/_ssl.py +0 -0
  17. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/adapters.py +0 -0
  18. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/const.py +0 -0
  19. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/detection.py +0 -0
  20. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/dispatch.py +0 -0
  21. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/models.py +0 -0
  22. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/__init__.py +0 -0
  23. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/async_client.py +0 -0
  24. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/connection.py +0 -0
  25. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/const.py +0 -0
  26. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/control.py +0 -0
  27. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/mqtt/models.py +0 -0
  28. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/phase_validation.py +0 -0
  29. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/protocol.py +0 -0
  30. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/py.typed +0 -0
  31. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/src/span_panel_api/schema_drift.py +0 -0
  32. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/conftest.py +0 -0
  33. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  34. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  35. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  36. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/flat_wire.json +0 -0
  37. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  38. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/panelbench_wire.json +0 -0
  39. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/v2/README.md +0 -0
  40. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/fixtures/v2/status.json +0 -0
  41. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/reference_payloads/README.md +0 -0
  42. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/reference_payloads/__init__.py +0 -0
  43. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/reference_payloads/bootstrap.py +0 -0
  44. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/reference_payloads/schema_one.py +0 -0
  45. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/circuits.response.txt +0 -0
  46. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/panel.response.txt +0 -0
  47. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/soe.response.txt +0 -0
  48. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/simulation_fixtures/status.response.txt +0 -0
  49. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_absent_readings_are_not_zero.py +0 -0
  50. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_accumulator.py +0 -0
  51. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_adapters_discovery.py +0 -0
  52. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_adopted_control.py +0 -0
  53. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_adoption.py +0 -0
  54. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_async_mqtt_client.py +0 -0
  55. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_auth_and_homie_helpers.py +0 -0
  56. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_auth_redaction.py +0 -0
  57. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_ca_pinning.py +0 -0
  58. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_catalog_divergence.py +0 -0
  59. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_control_interceptor.py +0 -0
  60. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_detection_auth.py +0 -0
  61. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_exceptions.py +0 -0
  62. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_factory_dispatch.py +0 -0
  63. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_field_metadata.py +0 -0
  64. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_https_transport.py +0 -0
  65. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_leaf_name_mismatch.py +0 -0
  66. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_live_flat_differential.py +0 -0
  67. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_mqtt_bridge.py +0 -0
  68. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_mqtt_client_connection.py +0 -0
  69. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_mqtt_connect_flow.py +0 -0
  70. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_mqtt_debounce.py +0 -0
  71. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_mqtt_homie.py +0 -0
  72. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_packaging.py +0 -0
  73. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_phase_validation_configs.py +0 -0
  74. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_phase_validation_errors.py +0 -0
  75. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_protocol_conformance.py +0 -0
  76. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_protocol_models.py +0 -0
  77. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_publish_outcome.py +0 -0
  78. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_redispatch_on_reconnect.py +0 -0
  79. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_reference_tree_values.py +0 -0
  80. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_rest_transport_contract.py +0 -0
  81. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_generation_cross_check.py +0 -0
  82. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_migration_delta.py +0 -0
  83. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_adapter.py +0 -0
  84. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_charge_limit.py +0 -0
  85. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_circuits.py +0 -0
  86. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_conformance.py +0 -0
  87. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_connection_health.py +0 -0
  88. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_control_refusal.py +0 -0
  89. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_devices.py +0 -0
  90. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_discovery.py +0 -0
  91. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_extension.py +0 -0
  92. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_panel.py +0 -0
  93. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_pcs.py +0 -0
  94. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_service_entrance.py +0 -0
  95. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_shed_forecast.py +0 -0
  96. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_snapshot.py +0 -0
  97. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_one_transport.py +0 -0
  98. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_provenance.py +0 -0
  99. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_schema_zero_adapter.py +0 -0
  100. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_shared_http_client.py +0 -0
  101. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_ssl_context.py +0 -0
  102. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/test_v2_status_parser.py +0 -0
  103. {span_panel_api-3.3.0 → span_panel_api-3.4.0}/tests/tls_fixtures.py +0 -0
@@ -7,6 +7,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
7
7
  Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the **last public release**, never against the beta before it. What one
8
8
  beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened.
9
9
 
10
+ ## [3.4.0]
11
+
12
+ A consumer that pinned the panel's CA could not put its schema fetches behind that pin, because the one port `SpanMqttClient` took served two transports with opposite security properties — the schema fetch, which should ride the pinned HTTPS transport, and
13
+ the bridge's CA download, which is plaintext by design because it fetches the very anchor everything else is checked against. This release splits them.
14
+
15
+ ### Added
16
+
17
+ - **`SpanMqttClient` takes `panel_https_port`**, and its schema fetches — the one at connect and every redispatch refetch — move to HTTPS on that port whenever an `ssl_context` is supplied, leaving the bridge's deliberately-plaintext CA fetches on
18
+ `panel_http_port` exactly where they were. Naming the HTTPS port without an anchor is refused rather than left silently plaintext, for the same reason `_build_url` refuses port 80 with a context.
19
+ - **`SpanPanelTLSVerificationError` names a bootstrap REST call that failed certificate verification**, as a subclass of `SpanPanelConnectionError` so every existing except clause keeps its meaning — raised only when an `ssl.SSLCertVerificationError` is in
20
+ the cause chain, because ambiguous evidence must not look terminal, and exported so a consumer that fails closed on an untrusted certificate can catch it before the parent.
21
+
22
+ ### Changed
23
+
24
+ - **`create_span_client`'s `port` lands in the slot its transport needs**: with an `ssl_context` it was already read as the HTTPS port by every REST call the factory makes, so it now reaches the client's HTTPS slot and the CA download takes the plaintext
25
+ default, instead of the TLS port being handed to a plaintext fetch.
26
+ - **The redispatch schema refetch no longer retries a certificate-verification failure**, which cannot succeed on a later attempt under the same anchor; it is left to raise and logged once per trigger, instead of a background task fetching every thirty
27
+ seconds forever while the log blames a slow boot.
28
+ - **The CA download no longer emits the plaintext-transport warning**, because the fetch of the anchor itself is unverifiable by construction and carries no credential — its trust posture is stated by each caller in its own voice, and the warning as it
29
+ stood named credentials that call never carries. Every other bootstrap call still warns, and the CA download no longer spends the once-per-host slot a genuinely plaintext call needs later.
30
+
10
31
  ## [3.3.0]
11
32
 
12
33
  A pinned panel that has moved is no longer reported the same way as a panel whose clock reset, so a consumer can put the remedy in front of a user instead of retrying in silence.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api
3
- Version: 3.3.0
3
+ Version: 3.4.0
4
4
  Summary: A client library for SPAN Panel API
5
5
  Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
6
  Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api"
3
- version = "3.3.0"
3
+ version = "3.4.0"
4
4
  description = "A client library for SPAN Panel API"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -30,6 +30,7 @@ from .exceptions import (
30
30
  SpanPanelServerError,
31
31
  SpanPanelStaleDataError,
32
32
  SpanPanelTimeoutError,
33
+ SpanPanelTLSVerificationError,
33
34
  SpanPanelValidationError,
34
35
  )
35
36
  from .factory import create_span_client
@@ -200,6 +201,11 @@ __all__ = [ # noqa: RUF022
200
201
  "SpanPanelError",
201
202
  "SpanPanelServerError",
202
203
  "SpanPanelStaleDataError",
204
+ # Added 2026-08-31 (3.4.0): a bootstrap REST call that failed verification
205
+ # rather than connection. A subclass of SpanPanelConnectionError, so every
206
+ # existing except clause keeps its meaning; a consumer that fails closed on
207
+ # an untrusted certificate catches this one before the parent.
208
+ "SpanPanelTLSVerificationError",
203
209
  "SpanPanelTimeoutError",
204
210
  "SpanPanelValidationError",
205
211
  ]
@@ -12,7 +12,13 @@ from typing import Literal
12
12
 
13
13
  import httpx
14
14
 
15
- from .exceptions import SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError, SpanPanelValidationError
15
+ from .exceptions import (
16
+ SpanPanelAPIError,
17
+ SpanPanelConnectionError,
18
+ SpanPanelTimeoutError,
19
+ SpanPanelTLSVerificationError,
20
+ SpanPanelValidationError,
21
+ )
16
22
 
17
23
  _LOGGER = logging.getLogger(__name__)
18
24
 
@@ -28,6 +34,10 @@ DEFAULT_HTTPS_PORT = 443
28
34
  #: each, so the two cannot drift apart the way their parsers had.
29
35
  V2_STATUS_PATH = "/api/v2/status"
30
36
 
37
+ #: The one bootstrap path exempt from the plaintext warning, named here because
38
+ #: the transport is what grants the exemption. See `_warn_plaintext_transport`.
39
+ CA_CERT_PATH = "/api/v2/certificate/ca"
40
+
31
41
  #: The verbs the bootstrap API uses. Spelled as a `Literal` rather than passed
32
42
  #: through to `client.request()` so the dispatch below stays exhaustive and each
33
43
  #: call still reaches the named httpx method.
@@ -161,9 +171,23 @@ def _reset_plaintext_warnings() -> None:
161
171
  _warned_plaintext_hosts.clear()
162
172
 
163
173
 
164
- def _warn_plaintext_transport(host: str, ssl_context: ssl.SSLContext | None) -> None:
174
+ def _warn_plaintext_transport(host: str, path: str, ssl_context: ssl.SSLContext | None) -> None:
165
175
  """Say out loud, once per panel, that its bootstrap traffic is not encrypted.
166
176
 
177
+ **The CA download is exempt, and does not claim the once-per-host slot.**
178
+ The warning exists so an operator can tell a security property is off when
179
+ it could be on, and for that endpoint there is no "on": verifying the fetch
180
+ of the anchor would require the anchor being fetched, an unverified-TLS
181
+ wrapping is readable and forgeable by the same active on-path attacker, and
182
+ the payload is a public certificate carrying no credential in either
183
+ direction — its authenticity control is the leaf check callers run *after*
184
+ the fetch. Each caller also states its own trust posture in its own voice:
185
+ the bridge's unpinned warning, a config flow's fingerprint confirmation, a
186
+ consumer's trust-on-first-use log. Warning here anyway named credentials the
187
+ call never carries, which is the line issue span#264 reported. Not marking
188
+ the host matters as much as not warning: a pinned consumer's diagnostic
189
+ re-read must not spend the slot a genuinely plaintext call needs later.
190
+
167
191
  In the same voice as the MQTT bridge's unpinned-CA warning, and for the same
168
192
  reason: a security property that is off by default is only a decision if the
169
193
  operator can tell it is off. ``ssl_context=None`` puts the request on
@@ -190,6 +214,8 @@ def _warn_plaintext_transport(host: str, ssl_context: ssl.SSLContext | None) ->
190
214
  """
191
215
  if ssl_context is not None:
192
216
  return
217
+ if path == CA_CERT_PATH:
218
+ return
193
219
  if host in _warned_plaintext_hosts:
194
220
  return
195
221
  _warned_plaintext_hosts.add(host)
@@ -299,7 +325,7 @@ async def _request(
299
325
  caller that supplied it.
300
326
  """
301
327
  url = _build_url(host, port, path, ssl_context)
302
- _warn_plaintext_transport(host, ssl_context)
328
+ _warn_plaintext_transport(host, path, ssl_context)
303
329
  try:
304
330
  async with _get_client(httpx_client, timeout, ssl_context) as client:
305
331
  match method:
@@ -314,5 +340,31 @@ async def _request(
314
340
  except httpx.TimeoutException as exc:
315
341
  raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
316
342
  except httpx.TransportError as exc:
343
+ if _is_certificate_verification_failure(exc):
344
+ raise SpanPanelTLSVerificationError(
345
+ f"{host} answered {path} with a certificate the supplied trust anchor rejects: {exc}"
346
+ ) from exc
317
347
  raise SpanPanelConnectionError(f"Cannot reach panel at {host}: {exc}") from exc
318
348
  return _Reply(host=host, endpoint=path, response=response)
349
+
350
+
351
+ def _is_certificate_verification_failure(exc: BaseException) -> bool:
352
+ """Whether this transport failure is demonstrably about certificate verification.
353
+
354
+ httpx wraps the underlying ``ssl.SSLCertVerificationError`` rather than
355
+ exposing it, so the evidence lives in the cause chain. Only that exact class
356
+ counts: a handshake that dies any other way -- a reset, a protocol mismatch,
357
+ an alert from a peer that is not TLS at all -- is indistinguishable from a
358
+ panel mid-reboot, and calling ambiguous evidence "verification failed" would
359
+ let a transient outage masquerade as the one failure consumers treat as
360
+ terminal. The walk is capped because ``__context__`` chains are
361
+ caller-assembled and nothing here should trust one to be finite.
362
+ """
363
+ seen = 0
364
+ current: BaseException | None = exc
365
+ while current is not None and seen < 10:
366
+ if isinstance(current, ssl.SSLCertVerificationError):
367
+ return True
368
+ current = current.__cause__ if current.__cause__ is not None else current.__context__
369
+ seen += 1
370
+ return False
@@ -18,7 +18,7 @@ import uuid
18
18
 
19
19
  import httpx
20
20
 
21
- from ._http import V2_STATUS_PATH, _request
21
+ from ._http import CA_CERT_PATH, V2_STATUS_PATH, _request
22
22
  from .exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelServerError
23
23
  from .models import HomieSchemaTypes, V2AuthResponse, V2HomieSchema, V2StatusInfo
24
24
 
@@ -373,7 +373,7 @@ async def download_ca_cert(
373
373
  "GET",
374
374
  host,
375
375
  port,
376
- "/api/v2/certificate/ca",
376
+ CA_CERT_PATH,
377
377
  timeout=timeout,
378
378
  httpx_client=httpx_client,
379
379
  ssl_context=ssl_context,
@@ -13,6 +13,25 @@ class SpanPanelConnectionError(SpanPanelError):
13
13
  """Connection to SPAN panel failed."""
14
14
 
15
15
 
16
+ class SpanPanelTLSVerificationError(SpanPanelConnectionError):
17
+ """Something answered a bootstrap REST call with a certificate the supplied anchor rejects.
18
+
19
+ A subclass of `SpanPanelConnectionError` on purpose: every consumer that
20
+ catches the parent and retries keeps doing exactly what it did, because
21
+ nothing raised this before an `ssl_context` reached the bootstrap calls. The
22
+ subclass exists for the consumer that wants the opposite of a retry — a
23
+ verification failure is not "the panel is not up yet", it is "whatever is up
24
+ does not hold a key the pin signs", and retrying that is waiting to succeed
25
+ against whatever is answering. Catch this before the parent to fail closed.
26
+
27
+ Raised only when the failure is demonstrably about verification — an
28
+ `ssl.SSLCertVerificationError` in the cause chain. Every other transport
29
+ failure, TLS handshakes that die for other reasons included, stays a plain
30
+ `SpanPanelConnectionError`, because ambiguous evidence must not look
31
+ terminal.
32
+ """
33
+
34
+
16
35
  class SpanPanelTimeoutError(SpanPanelError):
17
36
  """Request timed out."""
18
37
 
@@ -45,7 +45,14 @@ async def create_span_client(
45
45
  serial_number: Panel serial number (extracted from detection/registration if omitted).
46
46
  port: Port of the panel bootstrap API used for registration, detection and the
47
47
  schema fetch. ``None`` takes the scheme default -- 80 plaintext, 443 with a
48
- context.
48
+ context. It reaches the constructed client in the slot matching its
49
+ transport: ``panel_https_port`` with a context, ``panel_http_port`` without
50
+ -- so a pinned client's plaintext CA fetches never dial the TLS port.
51
+ The corollary is stated rather than hidden: under a context the bridge's
52
+ diagnostic CA re-read takes the plaintext default, port 80. A pinned
53
+ caller whose panel serves plaintext on a nonstandard port has no way to
54
+ say so through this factory; construct ``SpanMqttClient`` directly and
55
+ pass both ports.
49
56
  httpx_client: Optional shared ``httpx.AsyncClient``, used for every request this
50
57
  makes and handed to the client it builds. Not closed here; its timeouts and
51
58
  limits are the caller's, which is why the per-call ``timeout`` defaults are
@@ -115,11 +122,17 @@ async def create_span_client(
115
122
  # `adapters` — none of it is safe to run on an event loop.
116
123
  adapter_cls = await asyncio.to_thread(resolve_adapter, adapter_key, dispatch_reason)
117
124
 
125
+ # `port` follows the transport the factory's own REST calls just used it
126
+ # for: with an ssl_context it was the HTTPS port (`_build_url` accepts no
127
+ # other reading), so it lands in the HTTPS slot and the bridge's
128
+ # deliberately-plaintext CA download keeps its own default. Without one it
129
+ # is the plaintext port, exactly as before.
118
130
  client = SpanMqttClient(
119
131
  host,
120
132
  serial_number,
121
133
  mqtt_config,
122
- panel_http_port=port,
134
+ panel_http_port=None if ssl_context is not None else port,
135
+ panel_https_port=port if ssl_context is not None else None,
123
136
  adapter_factory=adapter_cls,
124
137
  data_model_version=schema.data_model_version,
125
138
  schema_dispatch_reason=dispatch_reason,
@@ -33,6 +33,8 @@ from ..exceptions import (
33
33
  SpanPanelServerError,
34
34
  SpanPanelStaleDataError,
35
35
  SpanPanelTimeoutError,
36
+ SpanPanelTLSVerificationError,
37
+ SpanPanelValidationError,
36
38
  )
37
39
  from ..models import AdoptedProperty, ControlTarget, FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot, V2HomieSchema
38
40
  from ..protocol import PanelCapability, SchemaAdapter
@@ -131,6 +133,7 @@ class SpanMqttClient:
131
133
  broker_config: MqttClientConfig,
132
134
  snapshot_interval: float = 1.0,
133
135
  panel_http_port: int | None = None,
136
+ panel_https_port: int | None = None,
134
137
  adapter_factory: Callable[[str, V2HomieSchema], SchemaAdapter] | None = None,
135
138
  data_model_version: str | None = None,
136
139
  schema_dispatch_reason: str | None = None,
@@ -139,11 +142,29 @@ class SpanMqttClient:
139
142
  ssl_context: ssl.SSLContext | None = None,
140
143
  control_deadlines: ControlDeadlines | None = None,
141
144
  ) -> None:
145
+ if panel_https_port is not None and ssl_context is None:
146
+ # A TLS port with nothing to verify against is a decision nobody
147
+ # made: accepted silently, the schema fetch would run plaintext HTTP
148
+ # against a port the caller believes is TLS. The same misreading
149
+ # `_build_url` refuses for port 80 with a context, from the other
150
+ # direction.
151
+ raise SpanPanelValidationError(
152
+ f"panel_https_port={panel_https_port} was passed without an ssl_context for {host}. "
153
+ "Supply the pinned CA as ssl_context, or omit panel_https_port to stay on plaintext HTTP."
154
+ )
142
155
  self._host = host
143
156
  self._serial_number = serial_number
144
157
  self._broker_config = broker_config
145
158
  self._snapshot_interval = snapshot_interval
159
+ # Two ports because they serve transports with opposite security
160
+ # properties. `panel_http_port` is the plaintext one, and it belongs to
161
+ # the bridge: the CA download is unauthenticated by construction — it
162
+ # fetches the very anchor everything else is checked against — so it
163
+ # never follows the pin. `panel_https_port` carries this client's own
164
+ # schema fetches once an `ssl_context` anchors them; `None` with a
165
+ # context means `_build_url`'s TLS default, 443.
146
166
  self._panel_http_port = panel_http_port
167
+ self._panel_https_port = panel_https_port
147
168
  self._adapter_factory = adapter_factory
148
169
  # Shared by the caller, owned by the caller: never closed here, and its
149
170
  # policy -- timeouts, limits, headers -- is whatever the caller set. That
@@ -222,10 +243,18 @@ class SpanMqttClient:
222
243
  four arguments spelled out separately, and adding the trust anchor to one
223
244
  and not the other is exactly how a session ends up bootstrapping over
224
245
  HTTPS and refetching over HTTP for the rest of its life. One call site.
246
+
247
+ The port follows the transport. With an anchor the fetch is HTTPS and
248
+ takes ``panel_https_port``; without one it is plaintext and takes
249
+ ``panel_http_port``, exactly as it always did. Handing the HTTP port to
250
+ a TLS call is the combination ``_build_url`` refuses, and handing the
251
+ TLS port to the plaintext one is the constructor refusal -- so by the
252
+ time this runs, the pairing is already known good.
225
253
  """
254
+ port = self._panel_https_port if self._ssl_context is not None else self._panel_http_port
226
255
  return await get_homie_schema(
227
256
  self._host,
228
- port=self._panel_http_port,
257
+ port=port,
229
258
  httpx_client=self._httpx_client,
230
259
  ssl_context=self._ssl_context,
231
260
  )
@@ -1465,6 +1494,16 @@ class SpanMqttClient:
1465
1494
  while True:
1466
1495
  try:
1467
1496
  return await self._fetch_schema()
1497
+ except SpanPanelTLSVerificationError:
1498
+ # Before its parent, which the next clause would retry forever.
1499
+ # A verification failure cannot succeed on a later attempt --
1500
+ # the anchor is fixed for the session -- so it is precisely the
1501
+ # "error that is not the panel still coming up" this loop's
1502
+ # contract leaves to raise. The redispatch wrapper logs it once
1503
+ # per trigger, and escalation belongs to the MQTT side: a
1504
+ # rotated CA surfaces through the bridge's own diagnosis and
1505
+ # fatal-error channel, which reconnects share with this trigger.
1506
+ raise
1468
1507
  except (
1469
1508
  SpanPanelConnectionError,
1470
1509
  SpanPanelTimeoutError,
@@ -1516,6 +1555,23 @@ class SpanMqttClient:
1516
1555
  """
1517
1556
  try:
1518
1557
  await self._redispatch_once()
1558
+ except SpanPanelTLSVerificationError:
1559
+ # Before the catch-all, whose text blames a slow boot. This is the
1560
+ # one refetch failure that is not one: the schema endpoint answered
1561
+ # with a certificate the session's anchor rejects, which cannot fix
1562
+ # itself on a later attempt and must not steer a user investigating
1563
+ # an interception toward waiting. Still non-fatal here for the
1564
+ # catch-all's reason -- nothing may escape a fire-and-forget task --
1565
+ # and the next reconnect edge re-arms the attempt.
1566
+ _LOGGER.error(
1567
+ "Could not follow the panel's schema-generation change: the schema refetch "
1568
+ "failed certificate verification against the pinned CA, so the %r parser is "
1569
+ "unchanged. If the panel's CA rotated with the firmware, the broker "
1570
+ "connection will surface it; otherwise check what answers the panel's "
1571
+ "HTTPS port.",
1572
+ self._data_model_version,
1573
+ exc_info=True,
1574
+ )
1519
1575
  except Exception: # pylint: disable=broad-exception-caught
1520
1576
  # Nothing may escape here. This runs as a fire-and-forget task, so an
1521
1577
  # escaping exception becomes "Task exception was never retrieved" in
@@ -135,13 +135,23 @@ class TestItIsSaidOnce:
135
135
  assert len(_warnings(caplog)) == 1
136
136
 
137
137
  @pytest.mark.asyncio
138
- async def test_repeated_ca_fetches_on_fresh_clients_warn_once(self, caplog: pytest.LogCaptureFixture) -> None:
139
- """The reconnect path, and the reason this is scoped to the panel.
140
-
141
- `download_ca_cert` is called on every MQTT reconnect with no injected
142
- client, so each call builds its own. Anything keyed on the client object
143
- would warn once per reconnect — exactly the log the bridge's own
144
- once-per-bridge warning exists to avoid.
138
+ async def test_the_ca_download_never_warns(self, caplog: pytest.LogCaptureFixture) -> None:
139
+ """Reversed in 3.4.0, deliberately: this endpoint is unverifiable by construction.
140
+
141
+ The warning exists so an operator can tell a security property is *off*
142
+ when it could be on, and for the first fetch of the anchor itself there
143
+ is no "on": any verification would require the anchor being fetched, an
144
+ unverified-TLS wrapping would be readable and forgeable by the same
145
+ active on-path attacker, and the payload is a public certificate with no
146
+ credential in either direction — the authenticity control is the leaf
147
+ check its callers run *after* the fetch. Every caller also already says
148
+ so in its own voice: the bridge's once-per-bridge unpinned warning, the
149
+ config flow's fingerprint confirmation, and the deferred pin's
150
+ trust-on-first-use log line. Until 3.3.x this endpoint warned like the
151
+ rest, naming credentials it never carries; that line is what issue
152
+ span#264 reported. A caller that already holds the anchor and wants a
153
+ verified second copy passes ``ssl_context``, and no warning was ever in
154
+ question there.
145
155
  """
146
156
  with patch("span_panel_api._http.httpx.AsyncClient") as mock_cls:
147
157
  dedicated = AsyncMock()
@@ -154,6 +164,30 @@ class TestItIsSaidOnce:
154
164
  for _ in range(5):
155
165
  await download_ca_cert(HOST)
156
166
 
167
+ assert len(_warnings(caplog)) == 0
168
+
169
+ @pytest.mark.asyncio
170
+ async def test_the_ca_download_does_not_swallow_a_later_warning(self, caplog: pytest.LogCaptureFixture) -> None:
171
+ """Skipping the warning must not mark the host as already warned.
172
+
173
+ The diagnostic CA re-read runs on a *pinned* entry whose other calls are
174
+ HTTPS; if it claimed the once-per-host slot, a genuinely plaintext call
175
+ made later — a reauth on an entry that lost its pin — would say nothing.
176
+ """
177
+ answer = _json_response({"ebusBrokerPassword": "new-pass"}, method="PUT")
178
+ # Built before the patch below replaces `httpx.AsyncClient`; a spec
179
+ # against the patched class is a spec against a Mock, which mock refuses.
180
+ injected = _client("put", answer)
181
+ with patch("span_panel_api._http.httpx.AsyncClient") as mock_cls:
182
+ dedicated = AsyncMock()
183
+ dedicated.__aenter__ = AsyncMock(return_value=dedicated)
184
+ dedicated.__aexit__ = AsyncMock(return_value=False)
185
+ dedicated.get = AsyncMock(return_value=_text_response(PEM))
186
+ mock_cls.return_value = dedicated
187
+ with caplog.at_level(logging.WARNING):
188
+ await download_ca_cert(HOST)
189
+ await regenerate_passphrase(HOST, "jwt", httpx_client=injected)
190
+
157
191
  assert len(_warnings(caplog)) == 1
158
192
 
159
193
  @pytest.mark.asyncio
@@ -155,6 +155,11 @@ EXPECTED_PUBLIC_API = {
155
155
  "SpanPanelError",
156
156
  "SpanPanelServerError",
157
157
  "SpanPanelStaleDataError",
158
+ # Added 2026-08-31 (3.4.0): a bootstrap REST call that failed *verification*
159
+ # rather than connection. A subclass of SpanPanelConnectionError, so every
160
+ # existing except clause keeps its meaning; a consumer that wants to fail
161
+ # closed on an untrusted certificate catches this one first.
162
+ "SpanPanelTLSVerificationError",
158
163
  "SpanPanelTimeoutError",
159
164
  "SpanPanelValidationError",
160
165
  }
@@ -0,0 +1,276 @@
1
+ """The schema fetch and the CA download need different ports, and had one.
2
+
3
+ `panel_http_port` serves two transports with opposite security properties: the
4
+ bridge's CA download, which is plaintext by design because it fetches the very
5
+ anchor everything else is checked against, and the client's schema fetch, which
6
+ should ride the pinned HTTPS transport whenever the caller holds one. One
7
+ parameter fed both, so a consumer that pinned a CA could not move its schema
8
+ fetch to HTTPS without simultaneously pointing the CA download at a TLS port it
9
+ speaks plaintext to.
10
+
11
+ The split: `panel_https_port` carries the schema fetch when an `ssl_context` is
12
+ supplied, and `panel_http_port` keeps the bridge's deliberately-plaintext CA
13
+ fetches exactly where they were.
14
+
15
+ Alongside it, a TLS verification failure on a bootstrap REST call gets its own
16
+ exception class. A consumer that fails closed on an untrusted certificate needs
17
+ to tell "something answered with a certificate the pin does not sign" apart from
18
+ "nothing answered" — the first is terminal and needs a person, the second clears
19
+ itself when the panel finishes rebooting.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import contextlib
26
+ import logging
27
+ import ssl
28
+ from unittest.mock import AsyncMock, MagicMock, patch
29
+
30
+ import httpx
31
+ import pytest
32
+
33
+ from span_panel_api._http import _reset_plaintext_warnings
34
+ from span_panel_api.auth import get_homie_schema
35
+ from span_panel_api.exceptions import (
36
+ SpanPanelConnectionError,
37
+ SpanPanelTLSVerificationError,
38
+ SpanPanelValidationError,
39
+ )
40
+ from span_panel_api.mqtt import MqttClientConfig
41
+ from span_panel_api.mqtt.client import SpanMqttClient
42
+
43
+ from conftest import flat_schema
44
+
45
+ HOST = "192.168.1.1"
46
+ SERIAL = "sp3-242424-001"
47
+
48
+
49
+ @pytest.fixture(autouse=True)
50
+ def _fresh_warning_state() -> None:
51
+ """Keep the once-per-host warning set from leaking between tests."""
52
+ _reset_plaintext_warnings()
53
+
54
+
55
+ @pytest.fixture
56
+ def context() -> ssl.SSLContext:
57
+ """Any context object will do — nothing here completes a handshake."""
58
+ return ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
59
+
60
+
61
+ class _Schema:
62
+ def __init__(self, version: str | None) -> None:
63
+ self.data_model_version = version
64
+
65
+
66
+ def _client(**kwargs: object) -> SpanMqttClient:
67
+ return SpanMqttClient(
68
+ host=HOST,
69
+ serial_number=SERIAL,
70
+ broker_config=MqttClientConfig(broker_host="broker.local", username="u", password="p"),
71
+ **kwargs, # type: ignore[arg-type]
72
+ )
73
+
74
+
75
+ async def _run_connect_fetch(client: SpanMqttClient, fetch: AsyncMock) -> None:
76
+ """Drive connect() far enough to make its schema fetch, and no further."""
77
+ with (
78
+ patch("span_panel_api.mqtt.client.get_homie_schema", fetch),
79
+ patch.object(client, "_preload_adapter", AsyncMock()),
80
+ patch.object(client, "_build_adapter", MagicMock()),
81
+ ):
82
+ with contextlib.suppress(Exception):
83
+ await client.connect()
84
+ assert fetch.await_count >= 1
85
+
86
+
87
+ class TestSchemaFetchPortSelection:
88
+ @pytest.mark.asyncio
89
+ async def test_a_pinned_client_fetches_the_schema_on_the_https_port(self, context: ssl.SSLContext) -> None:
90
+ client = _client(panel_http_port=80, panel_https_port=8443, ssl_context=context)
91
+ fetch = AsyncMock(return_value=_Schema("1.0"))
92
+ await _run_connect_fetch(client, fetch)
93
+ assert fetch.await_args.kwargs["port"] == 8443
94
+ assert fetch.await_args.kwargs["ssl_context"] is context
95
+
96
+ @pytest.mark.asyncio
97
+ async def test_a_pinned_client_without_a_named_port_takes_the_scheme_default(self, context: ssl.SSLContext) -> None:
98
+ """`None` reaches `_build_url`, whose default for a TLS call is 443."""
99
+ client = _client(panel_http_port=80, ssl_context=context)
100
+ fetch = AsyncMock(return_value=_Schema("1.0"))
101
+ await _run_connect_fetch(client, fetch)
102
+ assert fetch.await_args.kwargs["port"] is None
103
+ assert fetch.await_args.kwargs["ssl_context"] is context
104
+
105
+ @pytest.mark.asyncio
106
+ async def test_an_unpinned_client_keeps_the_plaintext_port(self) -> None:
107
+ client = _client(panel_http_port=8080)
108
+ fetch = AsyncMock(return_value=_Schema("1.0"))
109
+ await _run_connect_fetch(client, fetch)
110
+ assert fetch.await_args.kwargs["port"] == 8080
111
+ assert fetch.await_args.kwargs["ssl_context"] is None
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_the_upgrade_refetch_uses_the_same_transport(self, context: ssl.SSLContext) -> None:
115
+ """One call site for both fetches is the point; prove the retry loop kept it."""
116
+ client = _client(panel_http_port=80, panel_https_port=8443, ssl_context=context)
117
+ client._loop = asyncio.get_running_loop()
118
+ fetch = AsyncMock(return_value=_Schema("1.0"))
119
+ with patch("span_panel_api.mqtt.client.get_homie_schema", fetch):
120
+ assert await client._fetch_schema_with_retry() is not None
121
+ assert fetch.await_args.kwargs["port"] == 8443
122
+ assert fetch.await_args.kwargs["ssl_context"] is context
123
+
124
+ def test_an_https_port_without_an_anchor_is_refused(self) -> None:
125
+ """A TLS port with nothing to verify against is a decision nobody made.
126
+
127
+ Accepting it silently would put the schema fetch on plaintext HTTP at a
128
+ port the caller believes is TLS — the same misreading `_build_url`
129
+ refuses for port 80 with a context, from the other direction.
130
+ """
131
+ with pytest.raises(SpanPanelValidationError):
132
+ _client(panel_https_port=8443)
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_the_bridge_keeps_the_plaintext_port_when_the_schema_fetch_moves(self, context: ssl.SSLContext) -> None:
136
+ """The CA download is plaintext by design and must not follow the pin."""
137
+ client = _client(panel_http_port=8080, panel_https_port=8443, ssl_context=context)
138
+ # A real schema, because this test needs connect() to get all the way
139
+ # to bridge construction rather than stopping at the fetch.
140
+ fetch = AsyncMock(return_value=flat_schema(32))
141
+ bridge_cls = MagicMock()
142
+ with (
143
+ patch("span_panel_api.mqtt.client.get_homie_schema", fetch),
144
+ patch("span_panel_api.mqtt.client.AsyncMqttBridge", bridge_cls),
145
+ patch.object(client, "_preload_adapter", AsyncMock()),
146
+ patch.object(client, "_build_adapter", MagicMock()),
147
+ ):
148
+ with contextlib.suppress(Exception):
149
+ await client.connect()
150
+ assert bridge_cls.call_args is not None
151
+ assert bridge_cls.call_args.kwargs["panel_http_port"] == 8080
152
+
153
+
154
+ class TestFactoryPortRouting:
155
+ """`create_span_client`'s one `port` lands in the slot its transport needs.
156
+
157
+ The factory's REST calls already read `port` as "the HTTPS port" when an
158
+ `ssl_context` rides along — `_build_url` refuses anything else — but it then
159
+ handed the same number to `panel_http_port`, pointing the bridge's
160
+ deliberately-plaintext CA download at a TLS port.
161
+ """
162
+
163
+ @pytest.mark.asyncio
164
+ async def test_a_pinned_factory_call_routes_its_port_to_the_https_slot(self, context: ssl.SSLContext) -> None:
165
+ from span_panel_api.adapters import _reset_adapter_cache
166
+ from span_panel_api.factory import create_span_client
167
+
168
+ _reset_adapter_cache()
169
+ config = MqttClientConfig(broker_host="broker.local", username="u", password="p")
170
+ with (
171
+ patch("span_panel_api.factory.SpanMqttClient") as mock_cls,
172
+ patch("span_panel_api.factory.get_homie_schema", return_value=_Schema(None)),
173
+ ):
174
+ mock_cls.return_value.connect = AsyncMock()
175
+ await create_span_client(
176
+ HOST,
177
+ mqtt_config=config,
178
+ serial_number=SERIAL,
179
+ port=8443,
180
+ ssl_context=context,
181
+ )
182
+ kwargs = mock_cls.call_args.kwargs
183
+ assert kwargs["panel_https_port"] == 8443
184
+ assert kwargs["ssl_context"] is context
185
+ # The CA download takes the plaintext default rather than the TLS port.
186
+ assert kwargs["panel_http_port"] is None
187
+
188
+ @pytest.mark.asyncio
189
+ async def test_an_unpinned_factory_call_keeps_its_port_on_the_plaintext_slot(self) -> None:
190
+ from span_panel_api.adapters import _reset_adapter_cache
191
+ from span_panel_api.factory import create_span_client
192
+
193
+ _reset_adapter_cache()
194
+ config = MqttClientConfig(broker_host="broker.local", username="u", password="p")
195
+ with (
196
+ patch("span_panel_api.factory.SpanMqttClient") as mock_cls,
197
+ patch("span_panel_api.factory.get_homie_schema", return_value=_Schema(None)),
198
+ ):
199
+ mock_cls.return_value.connect = AsyncMock()
200
+ await create_span_client(HOST, mqtt_config=config, serial_number=SERIAL, port=8080)
201
+ kwargs = mock_cls.call_args.kwargs
202
+ assert kwargs["panel_http_port"] == 8080
203
+ assert kwargs.get("panel_https_port") is None
204
+
205
+
206
+ class TestRedispatchRetryDoesNotRetryVerificationFailures:
207
+ """The retry loop's contract is 'the panel still coming up', and this is not that.
208
+
209
+ A verification failure cannot succeed on a later attempt under the same
210
+ context — the anchor is fixed for the session — so retrying it is a
211
+ background task GETting the panel every thirty seconds forever while the
212
+ log blames a slow boot. Left to raise, the redispatch wrapper logs the
213
+ failure once per trigger, and the MQTT side owns the escalation: a rotated
214
+ CA surfaces through the bridge's own diagnosis and fatal-error channel.
215
+ """
216
+
217
+ @pytest.mark.asyncio
218
+ async def test_a_verification_failure_raises_out_of_the_retry_loop(self, context: ssl.SSLContext) -> None:
219
+ client = _client(panel_https_port=8443, ssl_context=context)
220
+ client._loop = asyncio.get_running_loop()
221
+ fetch = AsyncMock(side_effect=SpanPanelTLSVerificationError("cert rejected by the pin"))
222
+ with patch("span_panel_api.mqtt.client.get_homie_schema", fetch):
223
+ # wait_for, because the defect this guards against is an infinite
224
+ # retry loop with real backoff sleeps: without it, a regression
225
+ # hangs the suite instead of failing it.
226
+ with pytest.raises(SpanPanelTLSVerificationError):
227
+ await asyncio.wait_for(client._fetch_schema_with_retry(), timeout=5)
228
+ # One attempt, no backoff loop: the failure is not retried at all.
229
+ assert fetch.await_count == 1
230
+
231
+ @pytest.mark.asyncio
232
+ async def test_the_escaping_failure_is_logged_as_what_it_is(
233
+ self, context: ssl.SSLContext, caplog: pytest.LogCaptureFixture
234
+ ) -> None:
235
+ """The wrapper's catch-all blamed a slow boot; verification is not that.
236
+
237
+ 'Reload once the panel is fully back up' is accidentally workable advice
238
+ — a reload does surface the repair — but the sentence points at the
239
+ wrong cause, and the wrong cause is the one a user investigating an
240
+ interception must not be steered away from.
241
+ """
242
+ client = _client(panel_https_port=8443, ssl_context=context)
243
+ client._loop = asyncio.get_running_loop()
244
+ failure = SpanPanelTLSVerificationError("cert rejected by the pin")
245
+ with patch.object(client, "_redispatch_once", AsyncMock(side_effect=failure)):
246
+ with caplog.at_level(logging.ERROR):
247
+ await client._redispatch_if_generation_changed()
248
+ assert "certificate verification" in caplog.text
249
+ assert "fully back up" not in caplog.text
250
+
251
+
252
+ class TestTLSVerificationFailureIsNamed:
253
+ @pytest.mark.asyncio
254
+ async def test_a_certificate_verification_failure_is_its_own_error(self) -> None:
255
+ """The one transport failure that must not be retried into submission."""
256
+ verify_failure = ssl.SSLCertVerificationError("certificate verify failed: unable to get local issuer certificate")
257
+ wrapped = httpx.ConnectError("[SSL: CERTIFICATE_VERIFY_FAILED]")
258
+ wrapped.__cause__ = verify_failure
259
+ injected = MagicMock()
260
+ injected.get = AsyncMock(side_effect=wrapped)
261
+
262
+ with pytest.raises(SpanPanelTLSVerificationError) as excinfo:
263
+ await get_homie_schema(HOST, httpx_client=injected)
264
+ # Still a connection error, so a consumer holding the 3.x contract —
265
+ # catch SpanPanelConnectionError, retry — keeps working unchanged.
266
+ assert isinstance(excinfo.value, SpanPanelConnectionError)
267
+
268
+ @pytest.mark.asyncio
269
+ async def test_an_ordinary_connect_failure_stays_a_connection_error(self) -> None:
270
+ """A refused socket is 'not up yet', and must not look terminal."""
271
+ injected = MagicMock()
272
+ injected.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
273
+
274
+ with pytest.raises(SpanPanelConnectionError) as excinfo:
275
+ await get_homie_schema(HOST, httpx_client=injected)
276
+ assert not isinstance(excinfo.value, SpanPanelTLSVerificationError)
File without changes
File without changes