span-panel-api 3.3.0__tar.gz → 3.4.1__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.1}/CHANGELOG.md +31 -0
  2. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/PKG-INFO +1 -1
  3. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/pyproject.toml +1 -1
  4. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/__init__.py +6 -0
  5. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/_http.py +73 -4
  6. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/auth.py +2 -2
  7. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/exceptions.py +19 -0
  8. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/factory.py +15 -2
  9. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/mqtt/client.py +57 -1
  10. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_plaintext_warning.py +91 -8
  11. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_public_api_unchanged.py +5 -0
  12. span_panel_api-3.4.1/tests/test_schema_fetch_transport_split.py +276 -0
  13. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/.gitignore +0 -0
  14. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/LICENSE +0 -0
  15. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/README.md +0 -0
  16. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/_ssl.py +0 -0
  17. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/adapters.py +0 -0
  18. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/const.py +0 -0
  19. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/detection.py +0 -0
  20. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/dispatch.py +0 -0
  21. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/models.py +0 -0
  22. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/mqtt/__init__.py +0 -0
  23. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/mqtt/async_client.py +0 -0
  24. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/mqtt/connection.py +0 -0
  25. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/mqtt/const.py +0 -0
  26. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/mqtt/control.py +0 -0
  27. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/mqtt/models.py +0 -0
  28. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/phase_validation.py +0 -0
  29. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/protocol.py +0 -0
  30. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/py.typed +0 -0
  31. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/src/span_panel_api/schema_drift.py +0 -0
  32. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/conftest.py +0 -0
  33. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/fixtures/configs/simulation_config_32_circuit.yaml +0 -0
  34. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/fixtures/configs/simulation_config_40_circuit_with_battery.yaml +0 -0
  35. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/fixtures/configs/simulation_config_8_tab_workshop.yaml +0 -0
  36. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/fixtures/flat_wire.json +0 -0
  37. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/fixtures/panelbench_unvalued_by_both.json +0 -0
  38. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/fixtures/panelbench_wire.json +0 -0
  39. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/fixtures/v2/README.md +0 -0
  40. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/fixtures/v2/status.json +0 -0
  41. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/reference_payloads/README.md +0 -0
  42. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/reference_payloads/__init__.py +0 -0
  43. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/reference_payloads/bootstrap.py +0 -0
  44. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/reference_payloads/schema_one.py +0 -0
  45. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/simulation_fixtures/circuits.response.txt +0 -0
  46. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/simulation_fixtures/panel.response.txt +0 -0
  47. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/simulation_fixtures/soe.response.txt +0 -0
  48. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/simulation_fixtures/status.response.txt +0 -0
  49. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_absent_readings_are_not_zero.py +0 -0
  50. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_accumulator.py +0 -0
  51. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_adapters_discovery.py +0 -0
  52. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_adopted_control.py +0 -0
  53. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_adoption.py +0 -0
  54. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_async_mqtt_client.py +0 -0
  55. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_auth_and_homie_helpers.py +0 -0
  56. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_auth_redaction.py +0 -0
  57. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_ca_pinning.py +0 -0
  58. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_catalog_divergence.py +0 -0
  59. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_control_interceptor.py +0 -0
  60. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_detection_auth.py +0 -0
  61. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_exceptions.py +0 -0
  62. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_factory_dispatch.py +0 -0
  63. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_field_metadata.py +0 -0
  64. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_https_transport.py +0 -0
  65. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_leaf_name_mismatch.py +0 -0
  66. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_live_flat_differential.py +0 -0
  67. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_mqtt_bridge.py +0 -0
  68. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_mqtt_client_connection.py +0 -0
  69. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_mqtt_connect_flow.py +0 -0
  70. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_mqtt_debounce.py +0 -0
  71. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_mqtt_homie.py +0 -0
  72. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_packaging.py +0 -0
  73. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_phase_validation_configs.py +0 -0
  74. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_phase_validation_errors.py +0 -0
  75. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_protocol_conformance.py +0 -0
  76. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_protocol_models.py +0 -0
  77. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_publish_outcome.py +0 -0
  78. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_redispatch_on_reconnect.py +0 -0
  79. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_reference_tree_values.py +0 -0
  80. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_rest_transport_contract.py +0 -0
  81. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_generation_cross_check.py +0 -0
  82. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_migration_delta.py +0 -0
  83. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_adapter.py +0 -0
  84. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_charge_limit.py +0 -0
  85. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_circuits.py +0 -0
  86. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_conformance.py +0 -0
  87. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_connection_health.py +0 -0
  88. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_control_refusal.py +0 -0
  89. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_devices.py +0 -0
  90. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_discovery.py +0 -0
  91. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_extension.py +0 -0
  92. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_panel.py +0 -0
  93. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_pcs.py +0 -0
  94. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_service_entrance.py +0 -0
  95. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_shed_forecast.py +0 -0
  96. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_snapshot.py +0 -0
  97. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_one_transport.py +0 -0
  98. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_provenance.py +0 -0
  99. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_schema_zero_adapter.py +0 -0
  100. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_shared_http_client.py +0 -0
  101. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_ssl_context.py +0 -0
  102. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/test_v2_status_parser.py +0 -0
  103. {span_panel_api-3.3.0 → span_panel_api-3.4.1}/tests/tls_fixtures.py +0 -0
@@ -7,6 +7,37 @@ 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.1]
11
+
12
+ A panel that merely advertises itself on the network no longer produces the plaintext-transport warning when discovery probes it, closing the remaining way issue span#264's log line reached an operator who could do nothing about it.
13
+
14
+ ### Changed
15
+
16
+ - **The status endpoint no longer emits the plaintext-transport warning**, for the CA download's reason from the other side: it is the detection probe made against devices nobody has configured, where no pin can exist and the only action is configuring the
17
+ panel — whose flow pins before any credential moves. It carries no credential in either direction, and it no longer spends the once-per-host warning slot, which it previously claimed first in every flow so that a genuinely credential-bearing call behind
18
+ it said nothing. Registration, passphrase rotation, and the schema fetch warn exactly as before.
19
+
20
+ ## [3.4.0]
21
+
22
+ 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
23
+ 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.
24
+
25
+ ### Added
26
+
27
+ - **`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
28
+ `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.
29
+ - **`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
30
+ 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.
31
+
32
+ ### Changed
33
+
34
+ - **`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
35
+ default, instead of the TLS port being handed to a plaintext fetch.
36
+ - **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
37
+ seconds forever while the log blames a slow boot.
38
+ - **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
39
+ 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.
40
+
10
41
  ## [3.3.0]
11
42
 
12
43
  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.1
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.1"
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
 
@@ -25,9 +31,16 @@ DEFAULT_HTTPS_PORT = 443
25
31
  #: The one bootstrap path two modules request: the detector probes it to decide
26
32
  #: whether the panel speaks v2 at all, and `get_v2_status` reads the same answer
27
33
  #: for a caller that already knows it does. Named here rather than spelled out in
28
- #: each, so the two cannot drift apart the way their parsers had.
34
+ #: each, so the two cannot drift apart the way their parsers had. Load-bearing
35
+ #: for `_warn_plaintext_transport`'s exemption: a request to this path is the
36
+ #: one the warning stays silent for, so a change here changes what warns.
29
37
  V2_STATUS_PATH = "/api/v2/status"
30
38
 
39
+ #: Exempt from the plaintext warning alongside `V2_STATUS_PATH`, named here
40
+ #: because the transport is what grants the exemption. See
41
+ #: `_warn_plaintext_transport`.
42
+ CA_CERT_PATH = "/api/v2/certificate/ca"
43
+
31
44
  #: The verbs the bootstrap API uses. Spelled as a `Literal` rather than passed
32
45
  #: through to `client.request()` so the dispatch below stays exhaustive and each
33
46
  #: call still reaches the named httpx method.
@@ -161,9 +174,37 @@ def _reset_plaintext_warnings() -> None:
161
174
  _warned_plaintext_hosts.clear()
162
175
 
163
176
 
164
- def _warn_plaintext_transport(host: str, ssl_context: ssl.SSLContext | None) -> None:
177
+ def _warn_plaintext_transport(host: str, path: str, ssl_context: ssl.SSLContext | None) -> None:
165
178
  """Say out loud, once per panel, that its bootstrap traffic is not encrypted.
166
179
 
180
+ **Two endpoints are exempt, and neither claims the once-per-host slot.**
181
+ The warning exists so an operator can tell a security property is off when
182
+ it could be on, and for both there is no "on". The CA download fetches the
183
+ very anchor verification would need — an unverified-TLS wrapping is
184
+ readable and forgeable by the same active on-path attacker, the payload is
185
+ a public certificate, and its authenticity control is the leaf check
186
+ callers run *after* the fetch. The status endpoint is the detection probe:
187
+ most prominently the request discovery makes against a device nobody has
188
+ configured, where no pin can exist because trust-on-first-use has not
189
+ happened and the only action available is configuring the panel — whose
190
+ flow pins before any credential moves, with a person confirming the
191
+ fingerprint. Neither call carries a credential in either direction, and
192
+ warning on them named credentials they never carry: the CA download's line
193
+ is what issue span#264 reported, and the status probe's is what a merely
194
+ *advertising* unconfigured panel produced at every boot.
195
+
196
+ Two limits of the status exemption, stated rather than implied. The body
197
+ informs decisions — `proximityProven`, the serial an identity check reads —
198
+ and a plaintext answer is one anything on the path can write; the controls
199
+ for that are the panel's own registration gate and the pin the consumer's
200
+ flow acquires before a credential moves, not a log line. And a consumer
201
+ *can* probe a configured, pinned panel's status without its context; the
202
+ exemption means no warning will point that out, so a consumer owes every
203
+ probe of a configured host the entry's own transport. Not marking the host matters as
204
+ much as not warning — the probe runs first in every flow and a diagnostic
205
+ re-read runs on pinned entries, and neither may spend the slot a
206
+ credential-bearing call needs later.
207
+
167
208
  In the same voice as the MQTT bridge's unpinned-CA warning, and for the same
168
209
  reason: a security property that is off by default is only a decision if the
169
210
  operator can tell it is off. ``ssl_context=None`` puts the request on
@@ -190,6 +231,8 @@ def _warn_plaintext_transport(host: str, ssl_context: ssl.SSLContext | None) ->
190
231
  """
191
232
  if ssl_context is not None:
192
233
  return
234
+ if path in (CA_CERT_PATH, V2_STATUS_PATH):
235
+ return
193
236
  if host in _warned_plaintext_hosts:
194
237
  return
195
238
  _warned_plaintext_hosts.add(host)
@@ -299,7 +342,7 @@ async def _request(
299
342
  caller that supplied it.
300
343
  """
301
344
  url = _build_url(host, port, path, ssl_context)
302
- _warn_plaintext_transport(host, ssl_context)
345
+ _warn_plaintext_transport(host, path, ssl_context)
303
346
  try:
304
347
  async with _get_client(httpx_client, timeout, ssl_context) as client:
305
348
  match method:
@@ -314,5 +357,31 @@ async def _request(
314
357
  except httpx.TimeoutException as exc:
315
358
  raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc
316
359
  except httpx.TransportError as exc:
360
+ if _is_certificate_verification_failure(exc):
361
+ raise SpanPanelTLSVerificationError(
362
+ f"{host} answered {path} with a certificate the supplied trust anchor rejects: {exc}"
363
+ ) from exc
317
364
  raise SpanPanelConnectionError(f"Cannot reach panel at {host}: {exc}") from exc
318
365
  return _Reply(host=host, endpoint=path, response=response)
366
+
367
+
368
+ def _is_certificate_verification_failure(exc: BaseException) -> bool:
369
+ """Whether this transport failure is demonstrably about certificate verification.
370
+
371
+ httpx wraps the underlying ``ssl.SSLCertVerificationError`` rather than
372
+ exposing it, so the evidence lives in the cause chain. Only that exact class
373
+ counts: a handshake that dies any other way -- a reset, a protocol mismatch,
374
+ an alert from a peer that is not TLS at all -- is indistinguishable from a
375
+ panel mid-reboot, and calling ambiguous evidence "verification failed" would
376
+ let a transient outage masquerade as the one failure consumers treat as
377
+ terminal. The walk is capped because ``__context__`` chains are
378
+ caller-assembled and nothing here should trust one to be finite.
379
+ """
380
+ seen = 0
381
+ current: BaseException | None = exc
382
+ while current is not None and seen < 10:
383
+ if isinstance(current, ssl.SSLCertVerificationError):
384
+ return True
385
+ current = current.__cause__ if current.__cause__ is not None else current.__context__
386
+ seen += 1
387
+ 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
@@ -26,7 +26,8 @@ from unittest.mock import AsyncMock, patch
26
26
  import httpx
27
27
  import pytest
28
28
 
29
- from span_panel_api.auth import download_ca_cert, regenerate_passphrase, register_v2
29
+ from span_panel_api.auth import download_ca_cert, get_v2_status, regenerate_passphrase, register_v2
30
+ from span_panel_api.detection import detect_api_version
30
31
  from span_panel_api.mqtt.models import MqttClientConfig
31
32
 
32
33
  HOST = "panel.invalid"
@@ -135,13 +136,23 @@ class TestItIsSaidOnce:
135
136
  assert len(_warnings(caplog)) == 1
136
137
 
137
138
  @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.
139
+ async def test_the_ca_download_never_warns(self, caplog: pytest.LogCaptureFixture) -> None:
140
+ """Reversed in 3.4.0, deliberately: this endpoint is unverifiable by construction.
141
+
142
+ The warning exists so an operator can tell a security property is *off*
143
+ when it could be on, and for the first fetch of the anchor itself there
144
+ is no "on": any verification would require the anchor being fetched, an
145
+ unverified-TLS wrapping would be readable and forgeable by the same
146
+ active on-path attacker, and the payload is a public certificate with no
147
+ credential in either direction — the authenticity control is the leaf
148
+ check its callers run *after* the fetch. Every caller also already says
149
+ so in its own voice: the bridge's once-per-bridge unpinned warning, the
150
+ config flow's fingerprint confirmation, and the deferred pin's
151
+ trust-on-first-use log line. Until 3.3.x this endpoint warned like the
152
+ rest, naming credentials it never carries; that line is what issue
153
+ span#264 reported. A caller that already holds the anchor and wants a
154
+ verified second copy passes ``ssl_context``, and no warning was ever in
155
+ question there.
145
156
  """
146
157
  with patch("span_panel_api._http.httpx.AsyncClient") as mock_cls:
147
158
  dedicated = AsyncMock()
@@ -154,6 +165,78 @@ class TestItIsSaidOnce:
154
165
  for _ in range(5):
155
166
  await download_ca_cert(HOST)
156
167
 
168
+ assert len(_warnings(caplog)) == 0
169
+
170
+ @pytest.mark.asyncio
171
+ async def test_the_status_probe_never_warns(self, caplog: pytest.LogCaptureFixture) -> None:
172
+ """Exempted in 3.4.1, for the CA download's reason from the other side.
173
+
174
+ The status endpoint is the detection probe — the request zeroconf
175
+ discovery makes against a device nobody has configured, once per boot,
176
+ where no pin can exist because trust-on-first-use has not happened and
177
+ there is nothing the operator can do but configure the panel. It
178
+ carries no credential in either direction, and the flows that probe it
179
+ on a configured-but-unpinned entry acquire the pin before any
180
+ credential moves, with a human confirming the fingerprint. The warning
181
+ here named credentials the call never carries and pointed at an action
182
+ nobody could take — the line that made an operator remove a healthy
183
+ emulator. The exemption's limits live in `_warn_plaintext_transport`'s
184
+ docstring: a consumer probing a *configured* host owes that probe the
185
+ entry's own transport, because no warning will say so anymore.
186
+ """
187
+ answer = _json_response({"serialNumber": "SYN-0000-0001", "firmwareVersion": "f"}, method="GET")
188
+ with caplog.at_level(logging.WARNING):
189
+ await get_v2_status(HOST, httpx_client=_client("get", answer))
190
+ assert len(_warnings(caplog)) == 0
191
+
192
+ @pytest.mark.asyncio
193
+ async def test_the_detection_probe_never_warns(self, caplog: pytest.LogCaptureFixture) -> None:
194
+ """Detection reads the same endpoint, and must be equally silent."""
195
+ answer = _json_response({"serialNumber": "SYN-0000-0001", "firmwareVersion": "f"}, method="GET")
196
+ with caplog.at_level(logging.WARNING):
197
+ await detect_api_version(HOST, httpx_client=_client("get", answer))
198
+ assert len(_warnings(caplog)) == 0
199
+
200
+ @pytest.mark.asyncio
201
+ async def test_the_status_probe_does_not_swallow_a_later_warning(self, caplog: pytest.LogCaptureFixture) -> None:
202
+ """The probe fires first in every flow, so it must not spend the slot.
203
+
204
+ Today it does exactly that: a reauth's status probe claims the
205
+ once-per-host warning, and the credential-bearing call behind it says
206
+ nothing. The warning belongs to whichever call actually deserves it.
207
+ """
208
+ status = _json_response({"serialNumber": "SYN-0000-0001", "firmwareVersion": "f"}, method="GET")
209
+ rotate = _json_response({"ebusBrokerPassword": "new-pass"}, method="PUT")
210
+ with caplog.at_level(logging.WARNING):
211
+ await get_v2_status(HOST, httpx_client=_client("get", status))
212
+ # The midpoint is the assertion: the slot must still be unspent
213
+ # here, so the warning below demonstrably belongs to the rotation.
214
+ assert len(_warnings(caplog)) == 0
215
+ await regenerate_passphrase(HOST, "jwt", httpx_client=_client("put", rotate))
216
+ assert len(_warnings(caplog)) == 1
217
+
218
+ @pytest.mark.asyncio
219
+ async def test_the_ca_download_does_not_swallow_a_later_warning(self, caplog: pytest.LogCaptureFixture) -> None:
220
+ """Skipping the warning must not mark the host as already warned.
221
+
222
+ The diagnostic CA re-read runs on a *pinned* entry whose other calls are
223
+ HTTPS; if it claimed the once-per-host slot, a genuinely plaintext call
224
+ made later — a reauth on an entry that lost its pin — would say nothing.
225
+ """
226
+ answer = _json_response({"ebusBrokerPassword": "new-pass"}, method="PUT")
227
+ # Built before the patch below replaces `httpx.AsyncClient`; a spec
228
+ # against the patched class is a spec against a Mock, which mock refuses.
229
+ injected = _client("put", answer)
230
+ with patch("span_panel_api._http.httpx.AsyncClient") as mock_cls:
231
+ dedicated = AsyncMock()
232
+ dedicated.__aenter__ = AsyncMock(return_value=dedicated)
233
+ dedicated.__aexit__ = AsyncMock(return_value=False)
234
+ dedicated.get = AsyncMock(return_value=_text_response(PEM))
235
+ mock_cls.return_value = dedicated
236
+ with caplog.at_level(logging.WARNING):
237
+ await download_ca_cert(HOST)
238
+ await regenerate_passphrase(HOST, "jwt", httpx_client=injected)
239
+
157
240
  assert len(_warnings(caplog)) == 1
158
241
 
159
242
  @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