From b6b1ab19340bb18979afa8401372e3b4bd7a96d3 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 05:03:29 -0400 Subject: [PATCH] Fix (P2): document the real unconfigured-token contract and pin it with a test The const.py comment claimed an empty stored token yields a client that answers 401 until configured, but NestQuestApiClient raises ValueError at construction for an empty/whitespace token. Keep the fail-fast guard, correct the documentation to state the actual contract (default token is the empty string; options flow validates non-empty; the coordinator must configure a token first or handle the construction error), and add a test pinning that client_from_entry with an unconfigured entry raises at construction instead of building a client that can only 401. --- custom_components/nestquest/core/const.py | 15 ++++++++---- tests/test_api_client.py | 28 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/custom_components/nestquest/core/const.py b/custom_components/nestquest/core/const.py index bbca00a..33868d0 100644 --- a/custom_components/nestquest/core/const.py +++ b/custom_components/nestquest/core/const.py @@ -109,10 +109,17 @@ DEFAULT_AUTOMATION_ENABLED = True # (``NESTQUEST_PANEL_TOKEN`` on the API side). Both are stored on the # entry like every other setting — options first, then the entry-data # copy, then defaults. The TOKEN IS A SECRET: it is never logged, never -# echoed into error messages, and never committed. No invented default -# token ships (an empty stored token means "not configured yet", and the -# panel plane answers 401 until a real one is saved) — mirroring the API -# service's fail-closed stance (api/config.py: no default token exists). +# echoed into error messages, and never committed. The shipped default +# token IS the empty string (DEFAULT_PANEL_TOKEN) — an entry with no +# configured token is therefore NOT a working client: the options flow +# validates that a saved token is non-empty (the user-facing guard), and +# NestQuestApiClient fails fast at construction on an empty/whitespace +# token (api_client.py). So a caller building the client from a bare +# entry must either ensure a token is configured first or handle the +# construction ValueError and surface an "API not configured" state — +# an unconfigured entry never yields a client that can only 401. +# This mirrors the API service's fail-closed stance (api/config.py: no +# default token exists). CONF_API_BASE_URL = "api_base_url" DEFAULT_API_BASE_URL = "http://127.0.0.1:8000" CONF_PANEL_TOKEN = "panel_token" diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 359cf4f..4e9d5a9 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -399,6 +399,34 @@ def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None: assert client._panel_token == "entry-token" +def test_client_from_entry_unconfigured_token_raises_at_construction( + monkeypatch, +) -> None: + """An entry with NO configured token (the shipped default) raises + ValueError AT CONSTRUCTION — the client's fail-fast guard — rather + than silently building a client that can only 401 (the documented + contract: the options flow's non-empty validation is the user-facing + guard; the coordinator must ensure a token is configured first or + handle the construction error as "API not configured").""" + fake_module = types.ModuleType("homeassistant.helpers.aiohttp_client") + fake_module.async_get_clientsession = lambda hass: object() + monkeypatch.setitem( + sys.modules, "homeassistant.helpers.aiohttp_client", fake_module + ) + entry = make_config_entry( + options={CONF_API_BASE_URL: DEFAULT_API_BASE_URL}, + ) + assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == "" + + with pytest.raises(ValueError, match="panel_token must be a non-empty"): + api_client.client_from_entry(object(), entry) + + # Same for a whitespace-only stored token: whitespace is not a token. + entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "}) + with pytest.raises(ValueError, match="panel_token must be a non-empty"): + api_client.client_from_entry(object(), entry_ws) + + async def test_client_logs_nothing() -> None: """The client emits no log records at all (the token must never reach a log line), success or failure."""