From a5086c9700160574baac4afd8e7b3aa48c509706 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 12:37:14 -0400 Subject: [PATCH 01/14] Handle optional endpoint availability with stale recovery --- aiopnsense/_typing.py | 33 +- aiopnsense/client_base.py | 33 +- aiopnsense/client_endpoint.py | 544 ++++++++++++++++++++++++------- aiopnsense/client_queue.py | 54 ++++ aiopnsense/client_transport.py | 140 ++++++++ aiopnsense/const.py | 2 + aiopnsense/nut.py | 6 +- aiopnsense/smart.py | 22 +- aiopnsense/speedtest.py | 27 +- aiopnsense/unbound.py | 7 +- aiopnsense/vnstat.py | 18 +- tests/test_client_endpoint.py | 571 +++++++++++++++++++++++++++++++-- tests/test_client_queue.py | 12 + tests/test_client_transport.py | 88 +++++ tests/test_nut.py | 138 ++++---- tests/test_smart.py | 147 +++++---- tests/test_speedtest.py | 150 +++++---- tests/test_unbound.py | 47 +-- tests/test_vnstat.py | 86 +++-- 19 files changed, 1704 insertions(+), 421 deletions(-) diff --git a/aiopnsense/_typing.py b/aiopnsense/_typing.py index 2f4d7af..78fdd0b 100644 --- a/aiopnsense/_typing.py +++ b/aiopnsense/_typing.py @@ -1,17 +1,32 @@ """Typing protocol contracts for aiopnsense mixins.""" +import asyncio from collections.abc import AsyncGenerator, MutableMapping from datetime import tzinfo -from typing import Any, Protocol +from typing import Any, Literal, Protocol class AiopnsenseClientProtocol(Protocol): """Structural typing contract used by split aiopnsense mixins.""" _throw_errors: bool + _endpoint_availability: dict[tuple[Literal["get", "post"], str], bool] + _endpoint_checked_at: dict[tuple[Literal["get", "post"], str], float] + _endpoint_locks: dict[tuple[Literal["get", "post"], str], asyncio.Lock] + _optional_endpoint_missing_pending_confirmation: set[tuple[Literal["get", "post"], str]] async def _get(self, path: str) -> MutableMapping[str, Any] | list | None: ... + async def _get_optional( + self, path: str + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: ... + + async def _post_optional( + self, + path: str, + payload: MutableMapping[str, Any] | None = None, + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: ... + async def _get_text(self, path: str) -> str | None: ... async def _post( @@ -57,3 +72,19 @@ async def _is_get_endpoint_available(self, path: str, force_refresh: bool = Fals async def _is_post_endpoint_available( self, path: str, force_refresh: bool = False ) -> bool | None: ... + + async def _check_optional_get_endpoint( + self, + path: str, + *, + force_refresh: bool = False, + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: ... + + async def _check_optional_post_endpoint( + self, + path: str, + payload: MutableMapping[str, Any] | None = None, + cache_path: str | None = None, + *, + force_refresh: bool = False, + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: ... diff --git a/aiopnsense/client_base.py b/aiopnsense/client_base.py index 05f3e58..87f10b6 100644 --- a/aiopnsense/client_base.py +++ b/aiopnsense/client_base.py @@ -2,8 +2,7 @@ import asyncio from collections.abc import MutableMapping -from datetime import datetime -from typing import Any +from typing import Any, Literal from urllib.parse import urlparse import warnings @@ -12,7 +11,7 @@ from .client_endpoint import ClientEndpointMixin from .client_queue import ClientQueueMixin from .client_transport import ClientTransportMixin -from .const import DEFAULT_CACHE_TTL_SECONDS +from .const import DEFAULT_CACHE_TTL_SECONDS, DEFAULT_NEGATIVE_CACHE_TTL_SECONDS from .exceptions import OPNsenseInvalidArgument _UNSET: object = object() @@ -41,7 +40,8 @@ def __init__( password (str): Password for API authentication. session (aiohttp.ClientSession): HTTP client session used for API requests. opts (MutableMapping[str, Any] | None, optional): Optional client configuration values - (e.g. ``opts={"verify_ssl": True}``). + such as ``verify_ssl``, ``endpoint_positive_cache_ttl_seconds``, + and ``endpoint_negative_cache_ttl_seconds``. initial (bool | object): Deprecated alias for ``throw_errors``. When provided, a ``DeprecationWarning`` is emitted. Ignored when ``throw_errors`` is also set. throw_errors (bool | object): Whether request and decorator errors should be @@ -79,9 +79,28 @@ def __init__( self._throw_errors = initial self._firmware_version: str | None = None self._use_snake_case: bool | None = None - self._endpoint_availability: dict[str, bool] = {} - self._endpoint_checked_at: dict[str, datetime] = {} - self._endpoint_cache_ttl_seconds = DEFAULT_CACHE_TTL_SECONDS + self._endpoint_availability: dict[tuple[Literal["get", "post"], str], bool] = {} + self._endpoint_checked_at: dict[tuple[Literal["get", "post"], str], float] = {} + self._endpoint_locks: dict[tuple[Literal["get", "post"], str], asyncio.Lock] = {} + self._optional_endpoint_missing_pending_confirmation: set[ + tuple[Literal["get", "post"], str] + ] = set() + positive_ttl = self._opts.get( + "endpoint_positive_cache_ttl_seconds", DEFAULT_CACHE_TTL_SECONDS + ) + negative_ttl = self._opts.get( + "endpoint_negative_cache_ttl_seconds", DEFAULT_NEGATIVE_CACHE_TTL_SECONDS + ) + if not isinstance(positive_ttl, int) or isinstance(positive_ttl, bool) or positive_ttl <= 0: + raise OPNsenseInvalidArgument( + "`endpoint_positive_cache_ttl_seconds` must be a positive integer." + ) + if not isinstance(negative_ttl, int) or isinstance(negative_ttl, bool) or negative_ttl <= 0: + raise OPNsenseInvalidArgument( + "`endpoint_negative_cache_ttl_seconds` must be a positive integer." + ) + self._endpoint_cache_ttl_seconds = positive_ttl + self._endpoint_negative_cache_ttl_seconds = negative_ttl self._rest_api_query_count = 0 self._request_queue: asyncio.Queue = asyncio.Queue() self._workers: list[asyncio.Task[Any]] = [] diff --git a/aiopnsense/client_endpoint.py b/aiopnsense/client_endpoint.py index a7786e4..60bf580 100644 --- a/aiopnsense/client_endpoint.py +++ b/aiopnsense/client_endpoint.py @@ -1,13 +1,18 @@ """Endpoint selection and availability helpers for OPNsenseClient.""" -from datetime import datetime -from typing import TYPE_CHECKING, cast +import asyncio +from collections.abc import MutableMapping +from time import monotonic +from typing import TYPE_CHECKING, Any, Literal, cast from warnings import deprecated import aiohttp from ._typing import AiopnsenseClientProtocol -from .const import DEFAULT_REQUEST_TIMEOUT_SECONDS, LEGACY_CAMELCASE_ENDPOINT_FIRMWARE +from .const import ( + DEFAULT_REQUEST_TIMEOUT_SECONDS, + LEGACY_CAMELCASE_ENDPOINT_FIRMWARE, +) from .exceptions import OPNsenseUnknownFirmware, _map_opnsense_exception, _opnsense_http_error from .helpers import _LOGGER, firmware_is_at_least @@ -16,9 +21,11 @@ class ClientEndpointMixin: """Endpoint selection and availability methods for OPNsenseClient.""" if TYPE_CHECKING: - _endpoint_availability: dict[str, bool] + _endpoint_availability: dict[tuple[Literal["get", "post"], str], bool] _endpoint_cache_ttl_seconds: int - _endpoint_checked_at: dict[str, datetime] + _endpoint_negative_cache_ttl_seconds: int + _endpoint_locks: dict[tuple[Literal["get", "post"], str], asyncio.Lock] + _optional_endpoint_missing_pending_confirmation: set[tuple[Literal["get", "post"], str]] _unsafe_post_endpoint_probe_paths: frozenset[str] | set[str] _unsafe_post_endpoint_probe_prefixes: tuple[str, ...] _unsafe_post_endpoint_probe_segments: frozenset[str] | set[str] @@ -30,7 +37,42 @@ class ClientEndpointMixin: _use_snake_case: bool | None _username: str _verify_ssl: bool + _endpoint_checked_at: dict[tuple[Literal["get", "post"], str], float] + async def _get_optional( + self, path: str + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: ... + + async def _post_optional( + self, + path: str, + payload: MutableMapping[str, Any] | None = None, + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: ... + + async def _safe_dict_get(self, path: str) -> dict[str, Any]: ... + + _CORE_FIRMWARE_STATUS_ENDPOINT = "/api/core/firmware/status" + _OPTIONAL_GET_ENDPOINTS: frozenset[str] = frozenset( + { + "/api/speedtest/service/showrecent", + "/api/speedtest/service/showstat", + "/api/nut/diagnostics/upsstatus", + "/api/unbound/settings/search_dnsbl", + "/api/vnstat/service/hourly", + "/api/vnstat/service/daily", + "/api/vnstat/service/monthly", + "/api/vnstat/service/yearly", + } + ) + _OPTIONAL_POST_ENDPOINTS: frozenset[str] = frozenset( + { + "/api/smart/service/list", + "/api/smart/service/info", + } + ) + _OPTIONAL_POST_CACHE_PATHS: dict[str, str] = { + "/api/smart/service/list/1": "/api/smart/service/list", + } _UNSAFE_POST_ENDPOINT_PROBE_PATHS: frozenset[str] = frozenset() _UNSAFE_POST_ENDPOINT_PROBE_PREFIXES: tuple[str, ...] = () _UNSAFE_POST_ENDPOINT_PROBE_SEGMENTS: frozenset[str] = frozenset( @@ -112,71 +154,123 @@ def _is_post_endpoint_probe_blocked(self, path: str) -> bool: return True return False - @deprecated("Endpoint style selection is internal. Direct calls are no longer needed.") - async def set_use_snake_case(self, initial: bool = False) -> None: - """Deprecated wrapper that preserves legacy ``initial`` compatibility.""" - await self._set_use_snake_case(initial=initial) + def _get_endpoint_cache_key(self, method: str, path: str) -> tuple[Literal["get", "post"], str]: + """Build an internal endpoint cache key for method and path.""" + if method == "post": + return ("post", path) + return ("get", path) - async def _set_use_snake_case(self, initial: bool = False) -> None: - """Set endpoint naming mode from the detected firmware version. + def _is_optional_endpoint(self, method: str, path: str) -> bool: + """Return whether a method/path pair is an explicit optional capability.""" + if method == "post": + return path in self._OPTIONAL_POST_ENDPOINTS + return path in self._OPTIONAL_GET_ENDPOINTS + + def _get_endpoint_cache_ttl_seconds( + self, + method: str, + path: str, + is_available: bool, + ) -> int: + """Return the endpoint cache TTL based on method/path and probe outcome.""" + if is_available is False and self._is_optional_endpoint(method, path): + return self._endpoint_negative_cache_ttl_seconds + return self._endpoint_cache_ttl_seconds + + def _is_endpoint_cache_fresh( + self, + method: str, + path: str, + is_available: bool, + checked_at: float, + ) -> bool: + """Return whether a cached availability result is still fresh.""" + ttl_seconds = self._get_endpoint_cache_ttl_seconds(method, path, is_available) + return monotonic() - checked_at < ttl_seconds + + def _get_cached_endpoint_availability( + self, + method: str, + path: str, + force_refresh: bool, + ) -> bool | None: + """Return cached optional endpoint state when valid and not expired. Args: - initial (bool): Whether to preserve the legacy unknown-firmware raise behavior. + method: HTTP method for the cache key. + path: Optional endpoint cache path. + force_refresh (bool): Whether to bypass cached availability. Returns: - None: This method updates internal client state only. - - Raises: - OPNsenseUnknownFirmware: Raised when ``initial`` is ``True`` and the firmware - version cannot be compared reliably. + bool | None: Cached availability state when cache is fresh, or ``None`` + when cache is missing, stale, or bypassed. """ - firmware_version = await cast( - AiopnsenseClientProtocol, - self, - ).get_host_firmware_version() - self._use_snake_case = True - if firmware_version is None: - _LOGGER.debug("Using snake_case endpoints because firmware version is unavailable") - if initial: - raise OPNsenseUnknownFirmware - return - uses_snake_case = firmware_is_at_least(firmware_version, LEGACY_CAMELCASE_ENDPOINT_FIRMWARE) - if uses_snake_case is False: - _LOGGER.debug( - "Using camelCase endpoints for OPNsense < %s", - LEGACY_CAMELCASE_ENDPOINT_FIRMWARE, - ) - self._use_snake_case = False - elif uses_snake_case is True: - _LOGGER.debug( - "Using snake_case endpoints for OPNsense >= %s", - LEGACY_CAMELCASE_ENDPOINT_FIRMWARE, - ) - else: - _LOGGER.debug( - "Unable to compare firmware version %s for endpoint style", - firmware_version, - ) - if initial: - raise OPNsenseUnknownFirmware + if force_refresh: + return None - async def _get_endpoint_path(self, snake_case_path: str, camel_case_path: str) -> str: - """Return the firmware-appropriate endpoint path. + cache_key = self._get_endpoint_cache_key(method, path) + cached_is_available = self._endpoint_availability.get(cache_key) + cached_at = self._endpoint_checked_at.get(cache_key) + if cached_is_available is None or cached_at is None: + return None - Args: - snake_case_path (str): Endpoint path for newer snake_case firmware. - camel_case_path (str): Endpoint path for older camelCase firmware. + if self._is_endpoint_cache_fresh(method, path, cached_is_available, cached_at): + return cached_is_available - Returns: - str: Selected endpoint path for the active firmware family. - """ - if self._use_snake_case is None: - await self._set_use_snake_case() - # _get_endpoint_path treats _use_snake_case as a three-state flag: - # None means _set_use_snake_case has not determined the endpoint style yet, - # True selects snake_case, and False selects camelCase. Use "is not False" - # so an indeterminate or newer-firmware value stays on the snake_case path. - return snake_case_path if self._use_snake_case is not False else camel_case_path + return None + + def _log_endpoint_transition( + self, + cache_key: tuple[Literal["get", "post"], str], + new_state: str, + reason: str, + ) -> None: + """Log a concise optional endpoint cache transition.""" + old_value = self._endpoint_availability.get(cache_key) + old_state = ( + "available" if old_value is True else "missing" if old_value is False else "unknown" + ) + _LOGGER.debug( + "Optional endpoint cache transition %s %s: %s -> %s (%s)", + cache_key[0].upper(), + cache_key[1], + old_state, + new_state, + reason, + ) + + def _refresh_positive_endpoint_observation( + self, + cache_key: tuple[Literal["get", "post"], str], + reason: str, + ) -> None: + """Refresh a registered positive optional endpoint observation.""" + self._log_endpoint_transition(cache_key, "available", reason) + self._endpoint_availability[cache_key] = True + self._endpoint_checked_at[cache_key] = monotonic() + self._optional_endpoint_missing_pending_confirmation.discard(cache_key) + + def _invalidate_endpoint_observation( + self, + cache_key: tuple[Literal["get", "post"], str], + reason: str, + ) -> None: + """Invalidate an exact optional observation and mark it pending confirmation.""" + self._log_endpoint_transition(cache_key, "pending", reason) + self._endpoint_availability.pop(cache_key, None) + self._endpoint_checked_at.pop(cache_key, None) + self._optional_endpoint_missing_pending_confirmation.add(cache_key) + + def _store_confirmed_negative_endpoint_observation( + self, + cache_key: tuple[Literal["get", "post"], str], + reason: str, + ) -> None: + """Store a confirmed optional endpoint absence with the negative TTL.""" + self._log_endpoint_transition(cache_key, "missing", reason) + self._endpoint_availability[cache_key] = False + self._endpoint_checked_at[cache_key] = monotonic() + self._optional_endpoint_missing_pending_confirmation.discard(cache_key) async def _is_endpoint_available( self, @@ -202,80 +296,91 @@ async def _is_endpoint_available( Side Effects: Increments the REST query counter for uncached probes and updates - endpoint availability caches. On transient transport or HTTP - response errors, cache entries for the method-aware key are removed - before returning ``False`` or re-raising in throw mode. + endpoint availability caches. """ if not isinstance(path, str) or not path: return False normalized_method = method.lower() if normalized_method not in {"get", "post"}: return False - cache_key = path if normalized_method == "get" else f"{normalized_method}:{path}" - - now = datetime.now().astimezone() - cache_is_fresh = ( - cache_key in self._endpoint_checked_at - and (now - self._endpoint_checked_at[cache_key]).total_seconds() - < self._endpoint_cache_ttl_seconds - ) - if not force_refresh and cache_is_fresh and cache_key in self._endpoint_availability: - return self._endpoint_availability[cache_key] - - self._rest_api_query_count += 1 - url: str = f"{self._url}{path}" - _LOGGER.debug("[is_%s_endpoint_available] url: %s", normalized_method, url) + cache_key = self._get_endpoint_cache_key(normalized_method, path) + cached_is_available = self._endpoint_availability.get(cache_key) + cached_at = self._endpoint_checked_at.get(cache_key) + if ( + not force_refresh + and cached_is_available is not None + and cached_at is not None + and self._is_endpoint_cache_fresh( + normalized_method, path, cached_is_available, cached_at + ) + ): + return cached_is_available - try: - request = getattr(self._session, normalized_method) - async with request( - url, - auth=aiohttp.BasicAuth(self._username, self._password), - timeout=aiohttp.ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT_SECONDS), - ssl=self._verify_ssl, - ) as response: - if response.ok: - self._endpoint_availability[cache_key] = True - self._endpoint_checked_at[cache_key] = now - return True - if response.status == 404: - self._endpoint_availability[cache_key] = False - self._endpoint_checked_at[cache_key] = now + cache_lock = self._endpoint_locks.setdefault(cache_key, asyncio.Lock()) + async with cache_lock: + cached_is_available = self._endpoint_availability.get(cache_key) + cached_at = self._endpoint_checked_at.get(cache_key) + if ( + not force_refresh + and cached_is_available is not None + and cached_at is not None + and self._is_endpoint_cache_fresh( + normalized_method, path, cached_is_available, cached_at + ) + ): + return cached_is_available + + self._rest_api_query_count += 1 + url = f"{self._url}{path}" + _LOGGER.debug("[is_%s_endpoint_available] url: %s", normalized_method, url) + + try: + request = getattr(self._session, normalized_method) + async with request( + url, + auth=aiohttp.BasicAuth(self._username, self._password), + timeout=aiohttp.ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT_SECONDS), + ssl=self._verify_ssl, + ) as response: + checked_at = monotonic() + if response.ok: + self._endpoint_availability[cache_key] = True + self._endpoint_checked_at[cache_key] = checked_at + return True + if response.status == 404: + self._endpoint_availability[cache_key] = False + self._endpoint_checked_at[cache_key] = checked_at + return False + + if response.status == 403: + _LOGGER.error( + "Permission Error in is_%s_endpoint_available. Path: %s. Ensure the OPNsense user connected to HA has appropriate access. Recommend full admin access", + normalized_method, + url, + ) + else: + _LOGGER.warning( + "Transient %s endpoint check failure for %s. Response %s: %s. Not caching result.", + normalized_method.upper(), + path, + response.status, + response.reason, + ) + if self._throw_errors: + raise _opnsense_http_error(response.status, response.reason) return False - - self._endpoint_availability.pop(cache_key, None) - self._endpoint_checked_at.pop(cache_key, None) - if response.status == 403: - _LOGGER.error( - "Permission Error in is_%s_endpoint_available. Path: %s. Ensure the OPNsense user connected to HA has appropriate access. Recommend full admin access", - normalized_method, - url, - ) - else: - _LOGGER.warning( - "Transient %s endpoint check failure for %s. Response %s: %s. Not caching result.", - normalized_method.upper(), - path, - response.status, - response.reason, - ) + except (aiohttp.ClientError, TimeoutError) as e: + _LOGGER.warning( + "%s endpoint availability check failed for %s. %s: %s. Not caching result.", + normalized_method.upper(), + path, + type(e).__name__, + e, + ) if self._throw_errors: - raise _opnsense_http_error(response.status, response.reason) + raise _map_opnsense_exception(e) from e return False - except (aiohttp.ClientError, TimeoutError) as e: - self._endpoint_availability.pop(cache_key, None) - self._endpoint_checked_at.pop(cache_key, None) - _LOGGER.warning( - "%s endpoint availability check failed for %s. %s: %s. Not caching result.", - normalized_method.upper(), - path, - type(e).__name__, - e, - ) - if self._throw_errors: - raise _map_opnsense_exception(e) from e - return False async def _is_get_endpoint_available(self, path: str, force_refresh: bool = False) -> bool: """Return whether a specific GET-probed API endpoint appears available. @@ -313,6 +418,197 @@ async def _is_post_endpoint_available( return None return await self._is_endpoint_available(path, method="post", force_refresh=force_refresh) + async def _check_optional_get_endpoint( + self, + path: str, + *, + force_refresh: bool = False, + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + """Request an explicitly optional GET and reconcile its cache observation.""" + return await self._check_optional_endpoint( + method="get", + path=path, + cache_path=path, + payload=None, + force_refresh=force_refresh, + ) + + async def _check_optional_post_endpoint( + self, + path: str, + payload: MutableMapping[str, Any] | None = None, + cache_path: str | None = None, + *, + force_refresh: bool = False, + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + """Request an explicitly read-only optional POST and reconcile its cache.""" + resolved_cache_path = cache_path or path + expected_cache_path = self._OPTIONAL_POST_CACHE_PATHS.get(path, path) + if resolved_cache_path != expected_cache_path: + _LOGGER.debug( + "Rejected optional POST cache mapping %s -> %s (expected %s)", + path, + resolved_cache_path, + expected_cache_path, + ) + return "unavailable", {} + return await self._check_optional_endpoint( + method="post", + path=path, + cache_path=resolved_cache_path, + payload=payload, + force_refresh=force_refresh, + ) + + async def _check_optional_endpoint( + self, + *, + method: Literal["get", "post"], + path: str, + cache_path: str, + payload: MutableMapping[str, Any] | None, + force_refresh: bool, + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + """Run one real optional request and reconcile its registered cache key.""" + if not path or not self._is_optional_endpoint(method, cache_path): + _LOGGER.debug("Unregistered optional endpoint: %s %s", method.upper(), path) + return "unavailable", {} + + cache_key = self._get_endpoint_cache_key(method, cache_path) + cache_lock = self._endpoint_locks.setdefault(cache_key, asyncio.Lock()) + async with cache_lock: + had_confirmed_negative = self._endpoint_availability.get(cache_key) is False + cached_state = self._get_cached_endpoint_availability(method, cache_path, force_refresh) + if cached_state is False: + return "missing", {} + + was_pending = cache_key in self._optional_endpoint_missing_pending_confirmation + if method == "post": + optional_state, response_payload = await self._post_optional(path, payload) + else: + optional_state, response_payload = await self._get_optional(path) + + if optional_state in {"available", "malformed"}: + self._refresh_positive_endpoint_observation(cache_key, f"real_{method}_success") + return optional_state, response_payload + + if optional_state == "unavailable": + return "unavailable", {} + + if not was_pending and not had_confirmed_negative: + self._invalidate_endpoint_observation(cache_key, f"real_{method}_404") + + if not await self._is_core_firmware_endpoint_healthy(): + _LOGGER.debug( + "Skipping optional endpoint confirmation because firmware status endpoint is unavailable: %s", + cache_path, + ) + return "unavailable", {} + + if was_pending or had_confirmed_negative: + self._store_confirmed_negative_endpoint_observation( + cache_key, f"confirmed_{method}_404" + ) + return "missing", {} + + async def _is_core_firmware_endpoint_healthy(self) -> bool: + """Return whether a fresh control request proves the router API is healthy. + + Returns: + bool: ``True`` only when the required firmware status endpoint + succeeds. This health check never changes endpoint cache state. + """ + self._rest_api_query_count += 1 + url = f"{self._url}{self._CORE_FIRMWARE_STATUS_ENDPOINT}" + _LOGGER.debug("[optional_endpoint_core_health] url: %s", url) + try: + async with self._session.get( + url, + auth=aiohttp.BasicAuth(self._username, self._password), + timeout=aiohttp.ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT_SECONDS), + ssl=self._verify_ssl, + ) as response: + if response.ok: + return True + _LOGGER.debug( + "Optional endpoint core health check returned %s: %s", + response.status, + response.reason, + ) + except (aiohttp.ClientError, TimeoutError) as err: + _LOGGER.debug( + "Optional endpoint core health check failed. %s: %s", + type(err).__name__, + err, + ) + return False + + @deprecated("Endpoint style selection is internal. Direct calls are no longer needed.") + async def set_use_snake_case(self, initial: bool = False) -> None: + """Deprecated wrapper that preserves legacy ``initial`` compatibility.""" + await self._set_use_snake_case(initial=initial) + + async def _set_use_snake_case(self, initial: bool = False) -> None: + """Set endpoint naming mode from the detected firmware version. + + Args: + initial (bool): Whether to preserve the legacy unknown-firmware raise behavior. + + Returns: + None: This method updates internal client state only. + + Raises: + OPNsenseUnknownFirmware: Raised when ``initial`` is ``True`` and the firmware + version cannot be compared reliably. + """ + firmware_version = await cast( + AiopnsenseClientProtocol, + self, + ).get_host_firmware_version() + self._use_snake_case = True + if firmware_version is None: + _LOGGER.debug("Using snake_case endpoints because firmware version is unavailable") + if initial: + raise OPNsenseUnknownFirmware + return + uses_snake_case = firmware_is_at_least(firmware_version, LEGACY_CAMELCASE_ENDPOINT_FIRMWARE) + if uses_snake_case is False: + _LOGGER.debug( + "Using camelCase endpoints for OPNsense < %s", + LEGACY_CAMELCASE_ENDPOINT_FIRMWARE, + ) + self._use_snake_case = False + elif uses_snake_case is True: + _LOGGER.debug( + "Using snake_case endpoints for OPNsense >= %s", + LEGACY_CAMELCASE_ENDPOINT_FIRMWARE, + ) + else: + _LOGGER.debug( + "Unable to compare firmware version %s for endpoint style", + firmware_version, + ) + if initial: + raise OPNsenseUnknownFirmware + + async def _get_endpoint_path(self, snake_case_path: str, camel_case_path: str) -> str: + """Return the firmware-appropriate endpoint path. + + Args: + snake_case_path (str): Endpoint path for newer snake_case firmware. + camel_case_path (str): Endpoint path for older camelCase firmware. + + Returns: + str: Selected endpoint path for the active firmware family. + """ + if self._use_snake_case is None: + await self._set_use_snake_case() + # _get_endpoint_path treats _use_snake_case as a three-state flag: + # None means _set_use_snake_case has not determined the endpoint style yet, + # True selects snake_case, and False selects camelCase. Use "is not False" + # so an indeterminate or newer-firmware value stays on the snake_case path. + return snake_case_path if self._use_snake_case is not False else camel_case_path + @deprecated("Endpoint availability probing is internal. Direct calls are no longer needed.") async def is_endpoint_available(self, path: str, force_refresh: bool = False) -> bool: """Backward-compatible alias for GET endpoint availability probing. diff --git a/aiopnsense/client_queue.py b/aiopnsense/client_queue.py index 847b3ff..9563754 100644 --- a/aiopnsense/client_queue.py +++ b/aiopnsense/client_queue.py @@ -37,6 +37,29 @@ async def _do_get_from_stream( """Execute a queued streaming GET request.""" ... + async def _do_optional_get( + self, + path: str, + caller: str = "Unknown", + ) -> tuple[ + Literal["available", "malformed", "missing", "unavailable"], + object, + ]: + """Execute a queued optional GET request.""" + ... + + async def _do_optional_post( + self, + path: str, + payload: MutableMapping[str, Any] | None = None, + caller: str = "Unknown", + ) -> tuple[ + Literal["available", "malformed", "missing", "unavailable"], + object, + ]: + """Execute a queued optional read-only POST request.""" + ... + async def _do_post( self, path: str, @@ -123,6 +146,29 @@ async def _get(self, path: str) -> MutableMapping[str, Any] | list | None: """ return await self._queue_request("get", path) + async def _get_optional( + self, path: str + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + """Queue an optional GET request and return the envelope response.""" + return cast( + tuple[Literal["available", "malformed", "missing", "unavailable"], object], + await self._queue_request("optional_get", path), + ) + + async def _post_optional( + self, + path: str, + payload: MutableMapping[str, Any] | None = None, + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + """Queue an optional read-only POST and return its envelope response.""" + return cast( + tuple[ + Literal["available", "malformed", "missing", "unavailable"], + object, + ], + await self._queue_request("optional_post", path, payload), + ) + async def _get_text(self, path: str) -> str | None: """Queue a GET request and return its text body. @@ -169,6 +215,14 @@ async def _process_queue(self) -> None: result = await self._do_get(path, caller) if future is not None and not future.done(): future.set_result(result) + elif method == "optional_get": + result = await self._do_optional_get(path, caller) + if future is not None and not future.done(): + future.set_result(result) + elif method == "optional_post": + result = await self._do_optional_post(path, payload, caller) + if future is not None and not future.done(): + future.set_result(result) elif method == "get_text": result = await self._do_get(path, caller, response_format="text") if future is not None and not future.done(): diff --git a/aiopnsense/client_transport.py b/aiopnsense/client_transport.py index 20fd43d..dd74d29 100644 --- a/aiopnsense/client_transport.py +++ b/aiopnsense/client_transport.py @@ -321,6 +321,146 @@ async def _do_get( return None + async def _do_optional_get( + self, path: str, caller: str = "Unknown" + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + """Execute an optional GET request immediately. + + Args: + path (str): API endpoint path to request. + caller (str): Caller name used for diagnostics and logging. + + Returns: + tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + Availability state and parsed response payload. + """ + self._rest_api_query_count += 1 + url: str = f"{self._url}{path}" + _LOGGER.debug("[optional_get] url: %s", url) + try: + async with self._session.get( + url, + auth=aiohttp.BasicAuth(self._username, self._password), + timeout=aiohttp.ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT_SECONDS), + ssl=self._verify_ssl, + ) as response: + _LOGGER.debug("[optional_get] Response %s: %s", response.status, response.reason) + if response.ok: + try: + return "available", await response.json(content_type=None) + except (ValueError, UnicodeDecodeError) as err: + _LOGGER.debug( + "Optional GET endpoint returned malformed JSON for %s: %s", + path, + err, + ) + return "malformed", {} + if response.status == 404: + _LOGGER.debug( + "Optional GET endpoint unavailable (HTTP 404). Path: %s (called by %s)", + path, + caller, + ) + return "missing", {} + if response.status == 403: + _LOGGER.error( + "Permission Error in optional_get (called by %s). Path: %s. Ensure the OPNsense user connected to HA has appropriate access. Recommend full admin access", + caller, + url, + ) + else: + _LOGGER.warning( + "Transient optional GET endpoint failure for %s. Response %s: %s", + path, + response.status, + response.reason, + ) + if self._throw_errors: + raise _opnsense_http_error(response.status, response.reason) + except (aiohttp.ClientError, TimeoutError) as e: + _LOGGER.warning( + "Optional GET endpoint availability check failed for %s. %s: %s.", + path, + type(e).__name__, + e, + ) + if self._throw_errors: + raise _map_opnsense_exception(e) from e + + return "unavailable", {} + + async def _do_optional_post( + self, + path: str, + payload: MutableMapping[str, Any] | None = None, + caller: str = "Unknown", + ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + """Execute an explicitly read-only optional POST immediately. + + Args: + path: API endpoint path to request. + payload: Optional JSON request payload. + caller: Caller name used for diagnostics and logging. + + Returns: + Availability state and decoded response payload. + """ + self._rest_api_query_count += 1 + url = f"{self._url}{path}" + _LOGGER.debug("[optional_post] url: %s", url) + try: + async with self._session.post( + url, + auth=aiohttp.BasicAuth(self._username, self._password), + json=payload, + timeout=aiohttp.ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT_SECONDS), + ssl=self._verify_ssl, + ) as response: + _LOGGER.debug("[optional_post] Response %s: %s", response.status, response.reason) + if response.ok: + try: + return "available", await response.json(content_type=None) + except (ValueError, UnicodeDecodeError) as err: + _LOGGER.debug( + "Optional POST endpoint returned malformed JSON for %s: %s", + path, + err, + ) + return "malformed", {} + if response.status == 404: + _LOGGER.debug( + "Optional POST endpoint unavailable (HTTP 404). Path: %s (called by %s)", + path, + caller, + ) + return "missing", {} + if response.status == 403: + _LOGGER.error( + "Permission Error in optional_post (called by %s). Path: %s. Ensure the OPNsense user connected to HA has appropriate access. Recommend full admin access", + caller, + url, + ) + else: + _LOGGER.warning( + "Transient optional POST endpoint failure for %s. Response %s: %s", + path, + response.status, + response.reason, + ) + if self._throw_errors: + raise _opnsense_http_error(response.status, response.reason) + except (aiohttp.ClientError, TimeoutError) as err: + _LOGGER.warning( + "Optional POST endpoint availability check failed for %s. %s: %s.", + path, + type(err).__name__, + err, + ) + if self._throw_errors: + raise _map_opnsense_exception(err) from err + + return "unavailable", {} + def _normalize_timeout_seconds(self, timeout_seconds: float | None) -> float: """Normalize per-call timeout values to a positive float in seconds. diff --git a/aiopnsense/const.py b/aiopnsense/const.py index 93a3f06..3228197 100644 --- a/aiopnsense/const.py +++ b/aiopnsense/const.py @@ -19,6 +19,8 @@ DEFAULT_REQUEST_TIMEOUT_SECONDS = 60 # Shared cache time-to-live, in seconds, for endpoint availability state. DEFAULT_CACHE_TTL_SECONDS = 6 * 60 * 60 +# Retry confirmed optional-plugin absence after five minutes. +DEFAULT_NEGATIVE_CACHE_TTL_SECONDS = 5 * 60 # Mapping of ambiguous timezone abbreviations to explicit IANA timezones. AMBIGUOUS_TZINFOS: dict[str, Any] = { diff --git a/aiopnsense/nut.py b/aiopnsense/nut.py index 2abe0c8..fa2f9d3 100644 --- a/aiopnsense/nut.py +++ b/aiopnsense/nut.py @@ -22,10 +22,12 @@ async def get_nut_ups_status(self) -> dict[str, Any]: dict[str, Any]: Decoded NUT UPS status payload, or an empty dictionary when the NUT diagnostics endpoint is unavailable. """ - if not await self._is_get_endpoint_available(NUT_DIAGNOSTICS_UPS_STATUS_ENDPOINT): + optional_state, raw_payload = await self._check_optional_get_endpoint( + NUT_DIAGNOSTICS_UPS_STATUS_ENDPOINT + ) + if optional_state != "available": _LOGGER.debug("NUT UPS status endpoint unavailable") return {} - raw_payload = await self._safe_dict_get(NUT_DIAGNOSTICS_UPS_STATUS_ENDPOINT) return self._normalize_nut_ups_status_payload(raw_payload) @staticmethod diff --git a/aiopnsense/smart.py b/aiopnsense/smart.py index f67594e..9ae6e2a 100644 --- a/aiopnsense/smart.py +++ b/aiopnsense/smart.py @@ -21,10 +21,13 @@ async def get_smart(self) -> list[dict[str, Any]]: Returns: list[dict[str, Any]]: SMART device rows returned by the detailed API. """ - if not await self._is_post_endpoint_available(SMART_SERVICE_LIST_ENDPOINT): + list_status, smart_info = await self._check_optional_post_endpoint( + SMART_SERVICE_DETAIL_ENDPOINT, + cache_path=SMART_SERVICE_LIST_ENDPOINT, + ) + if list_status != "available" or not isinstance(smart_info, MutableMapping): _LOGGER.debug("SMART plugin unavailable") return [] - smart_info = await self._safe_dict_post(SMART_SERVICE_DETAIL_ENDPOINT) devices = smart_info.get("devices", []) if not isinstance(devices, list): _LOGGER.debug( @@ -66,12 +69,17 @@ async def get_smart_info(self, device: str, info_type: str = "a") -> dict[str, A dict[str, Any]: Decoded SMART detail payload. Non-mapping outputs are wrapped under ``output`` to preserve a stable mapping API. """ - if not await self._is_post_endpoint_available(SMART_SERVICE_INFO_ENDPOINT): - _LOGGER.debug("SMART plugin unavailable") - return {} - response = await self._safe_dict_post( + info_payload = { + "device": device, + "type": info_type, + "json": True, + } + info_status, response = await self._check_optional_post_endpoint( SMART_SERVICE_INFO_ENDPOINT, - {"device": device, "type": info_type, "json": True}, + payload=info_payload, ) + if info_status != "available" or not isinstance(response, MutableMapping): + _LOGGER.debug("SMART plugin unavailable") + return {} output = response.get("output", {}) return dict(output) if isinstance(output, MutableMapping) else {"output": output} diff --git a/aiopnsense/speedtest.py b/aiopnsense/speedtest.py index 8bac295..add6c76 100644 --- a/aiopnsense/speedtest.py +++ b/aiopnsense/speedtest.py @@ -39,17 +39,29 @@ async def get_speedtest(self) -> dict[str, Any]: include min, max, sample count, and period bounds. Returns ``{"available": False}`` when the plugin endpoint is missing. """ - if not await self._is_get_endpoint_available(SPEEDTEST_SHOW_RECENT_ENDPOINT): + show_recent_state, show_recent_payload = await self._check_optional_get_endpoint( + SPEEDTEST_SHOW_RECENT_ENDPOINT + ) + if show_recent_state == "missing": _LOGGER.debug("Speedtest not installed") return {"available": False} + if show_recent_state != "available": + _LOGGER.debug("Speedtest primary endpoint unavailable") + return {"available": True, "last": {}, "average": {}} - show_recent = await self._safe_dict_get(SPEEDTEST_SHOW_RECENT_ENDPOINT) - if await self._is_get_endpoint_available(SPEEDTEST_SHOW_STAT_ENDPOINT): - show_stat = await self._safe_dict_get(SPEEDTEST_SHOW_STAT_ENDPOINT) + show_stat_state, show_stat_payload = await self._check_optional_get_endpoint( + SPEEDTEST_SHOW_STAT_ENDPOINT + ) + if show_stat_state == "available": + show_stat = show_stat_payload if isinstance(show_stat_payload, MutableMapping) else {} else: - _LOGGER.debug("Speedtest statistics endpoint unavailable") + if show_stat_state == "missing": + _LOGGER.debug("Speedtest statistics endpoint unavailable") + else: + _LOGGER.debug("Speedtest statistics probe unavailable") show_stat = {} + show_recent = show_recent_payload if isinstance(show_recent_payload, MutableMapping) else {} server_id, server_name = self._parse_recent_server(show_recent.get("server")) date = show_recent.get("date") if isinstance(show_recent.get("date"), str) else None url = show_recent.get("url") if isinstance(show_recent.get("url"), str) else None @@ -123,7 +135,10 @@ async def run_speedtest(self) -> dict[str, Any]: endpoint, or an empty mapping when the plugin endpoint is unavailable or returns a malformed payload. """ - if not await self._is_get_endpoint_available(SPEEDTEST_SHOW_RECENT_ENDPOINT): + optional_state, _payload = await self._check_optional_get_endpoint( + SPEEDTEST_SHOW_RECENT_ENDPOINT + ) + if optional_state != "available": _LOGGER.debug("Speedtest not installed") return {} diff --git a/aiopnsense/unbound.py b/aiopnsense/unbound.py index cb562af..b62cea2 100644 --- a/aiopnsense/unbound.py +++ b/aiopnsense/unbound.py @@ -181,11 +181,12 @@ async def get_unbound_blocklist(self) -> dict[str, Any]: ) return {"legacy": await self._get_unbound_blocklist_legacy()} - if not await self._is_get_endpoint_available(UNBOUND_SETTINGS_SEARCH_DNSBL_ENDPOINT): + dnsbl_status, dnsbl_raw = await self._check_optional_get_endpoint( + UNBOUND_SETTINGS_SEARCH_DNSBL_ENDPOINT + ) + if dnsbl_status != "available": _LOGGER.debug("Unbound DNSBL endpoint unavailable") return {} - - dnsbl_raw = await self._safe_dict_get(UNBOUND_SETTINGS_SEARCH_DNSBL_ENDPOINT) if not isinstance(dnsbl_raw, MutableMapping): return {} dnsbl_rows = dnsbl_raw.get("rows", []) diff --git a/aiopnsense/vnstat.py b/aiopnsense/vnstat.py index e15effa..5424161 100644 --- a/aiopnsense/vnstat.py +++ b/aiopnsense/vnstat.py @@ -65,14 +65,11 @@ async def _fetch_vnstat_for(self, endpoint: str, expected_period: str) -> dict[s Returns: dict[str, Any]: Parsed payload or fallback empty mapping when endpoint is unavailable. """ - if not await self._is_get_endpoint_available(endpoint): + status, payload = await self._check_optional_get_endpoint(endpoint) + if status != "available" or not isinstance(payload, MutableMapping): _LOGGER.debug("vnStat %s endpoint unavailable", expected_period) return {"period": expected_period, "interfaces": {}} - - return self._parse_vnstat_payload( - await self._safe_dict_get(endpoint), - expected_period=expected_period, - ) + return self._parse_vnstat_payload(payload, expected_period=expected_period) @_log_errors async def get_vnstat_metrics(self, period: str) -> dict[str, Any]: @@ -109,15 +106,12 @@ async def get_vnstat(self) -> MutableMapping[str, Any]: convenience byte counters for today, this month, yesterday, last month, and the last complete hour. """ - if not await self._is_get_endpoint_available(VNSTAT_HOURLY_ENDPOINT): + hourly_status, hourly_raw = await self._check_optional_get_endpoint(VNSTAT_HOURLY_ENDPOINT) + if hourly_status != "available" or not isinstance(hourly_raw, MutableMapping): _LOGGER.debug("vnStat not installed") return {"interfaces": {}, "interface_count": 0} - opnsense_tz = await self._get_opnsense_timezone() - hourly = self._parse_vnstat_payload( - await self._safe_dict_get(VNSTAT_HOURLY_ENDPOINT), - expected_period="hourly", - ) + hourly = self._parse_vnstat_payload(hourly_raw, expected_period="hourly") daily = await self._fetch_vnstat_for(VNSTAT_DAILY_ENDPOINT, "daily") monthly = await self._fetch_vnstat_for(VNSTAT_MONTHLY_ENDPOINT, "monthly") interface_names = self._collect_vnstat_interfaces(hourly, daily, monthly) diff --git a/tests/test_client_endpoint.py b/tests/test_client_endpoint.py index 66e9951..4c87d75 100644 --- a/tests/test_client_endpoint.py +++ b/tests/test_client_endpoint.py @@ -1,6 +1,8 @@ """Tests for client endpoint availability and endpoint-style selection.""" -from datetime import datetime, timedelta +import asyncio +import logging +from time import monotonic from typing import Any from unittest.mock import AsyncMock @@ -10,10 +12,12 @@ from aiopnsense.exceptions import ( OPNsenseConnectionError, OPNsenseInvalidAuth, + OPNsenseInvalidArgument, OPNsensePrivilegeMissing, OPNsenseSSLError, OPNsenseUnknownFirmware, ) +from aiopnsense.const import DEFAULT_NEGATIVE_CACHE_TTL_SECONDS from tests.conftest import FakeResponse, MakeClientFactory, make_mock_session_client @@ -103,12 +107,13 @@ def _get(*args: Any, **kwargs: Any) -> Any: session.get = _get try: path = "/api/test/endpoint" + cache_key = ("get", path) assert await client._is_get_endpoint_available(path) is False assert await client._is_get_endpoint_available(path) is False assert calls == 1 - assert path in client._endpoint_checked_at - client._endpoint_checked_at[path] = datetime.now().astimezone() - timedelta( - seconds=client._endpoint_cache_ttl_seconds + 1 + assert cache_key in client._endpoint_checked_at + client._endpoint_checked_at[cache_key] = monotonic() - ( + client._endpoint_cache_ttl_seconds + 1 ) assert await client._is_get_endpoint_available(path) is True assert calls == 2 @@ -198,11 +203,12 @@ def _get( session.get = _get try: path = "/api/test/endpoint" + cache_key = ("get", path) with pytest.raises(OPNsenseSSLError): await client._is_get_endpoint_available(path) assert calls == 1 - assert path not in client._endpoint_checked_at - assert path not in client._endpoint_availability + assert cache_key not in client._endpoint_checked_at + assert cache_key not in client._endpoint_availability finally: await client.async_close() @@ -288,11 +294,12 @@ def _get(*args: Any, **kwargs: Any) -> Any: session.get = _get try: path = "/api/test/endpoint" + cache_key = ("get", path) assert await client._is_get_endpoint_available(path) is False assert await client._is_get_endpoint_available(path) is False assert calls == 2 - assert path not in client._endpoint_checked_at - assert path not in client._endpoint_availability + assert cache_key not in client._endpoint_checked_at + assert cache_key not in client._endpoint_availability finally: await client.async_close() @@ -335,12 +342,13 @@ def _get(*args: Any, **kwargs: Any) -> Any: session.get = _get try: path = "/api/test/endpoint" + cache_key = ("get", path) with pytest.raises(OPNsenseInvalidAuth) as err: await client._is_get_endpoint_available(path) assert err.value.status == 401 assert calls == 1 - assert path not in client._endpoint_checked_at - assert path not in client._endpoint_availability + assert cache_key not in client._endpoint_checked_at + assert cache_key not in client._endpoint_availability finally: await client.async_close() @@ -419,10 +427,12 @@ def _post(*args: object, **kwargs: object) -> FakeResponse: session.post = _post try: - assert await client._is_post_endpoint_available("/api/test/endpoint") is True - assert await client._is_post_endpoint_available("/api/test/endpoint") is True + path = "/api/test/endpoint" + cache_key = ("post", path) + assert await client._is_post_endpoint_available(path) is True + assert await client._is_post_endpoint_available(path) is True assert calls == 1 - assert "post:/api/test/endpoint" in client._endpoint_checked_at + assert cache_key in client._endpoint_checked_at finally: await client.async_close() @@ -460,11 +470,12 @@ def _post(*args: object, **kwargs: object) -> FakeResponse: session.post = _post try: path = "/api/test/endpoint" + cache_key = ("post", path) assert await client._is_post_endpoint_available(path) is False assert await client._is_post_endpoint_available(path) is False assert calls == 1 - assert f"post:{path}" in client._endpoint_checked_at - assert path not in client._endpoint_checked_at + assert cache_key in client._endpoint_checked_at + assert ("get", path) not in client._endpoint_checked_at finally: await client.async_close() @@ -572,12 +583,531 @@ def _post(*_args: object, **_kwargs: object) -> FakeResponse: try: assert await client._is_post_endpoint_available(path) is None assert calls == 0 - assert path not in client._endpoint_availability - assert f"post:{path}" not in client._endpoint_checked_at + assert ("post", path) not in client._endpoint_availability + assert ("post", path) not in client._endpoint_checked_at + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_get_endpoint_refreshes_positive_state_and_payload( + monkeypatch: pytest.MonkeyPatch, make_client: MakeClientFactory +) -> None: + """Verify optional endpoint success keeps fetching fresh payload and updates timestamp.""" + client, _session = make_mock_session_client(make_client) + times = iter([1000.0, 1001.0, 1002.0]) + path = "/api/speedtest/service/showrecent" + cache_key = ("get", path) + monkeypatch.setattr( + "aiopnsense.client_endpoint.monotonic", + lambda: next(times), + ) + client._get_optional = AsyncMock(side_effect=[("available", {"a": 1}), ("available", {"a": 2})]) + + try: + first_state, first_payload = await client._check_optional_get_endpoint(path) + second_state, second_payload = await client._check_optional_get_endpoint(path) + + assert first_state == "available" + assert second_state == "available" + assert first_payload == {"a": 1} + assert second_payload == {"a": 2} + assert first_payload != second_payload + assert cache_key in client._endpoint_checked_at + assert client._endpoint_checked_at[cache_key] == 1002.0 + assert client._endpoint_checked_at[cache_key] > 1000.0 + assert client._endpoint_availability[cache_key] is True + assert client._get_optional.await_count == 2 + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_get_endpoint_missing_drops_cached_positive_without_transient_warning( + caplog: pytest.LogCaptureFixture, make_client: MakeClientFactory +) -> None: + """Verify cached optional availability transitions from True to missing cleanly.""" + client, _session = make_mock_session_client(make_client) + path = "/api/nut/diagnostics/upsstatus" + cache_key = ("get", path) + client._get_optional = AsyncMock( + side_effect=[("available", {"status": {"x": 1}}), ("missing", {})] + ) + client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=True) + + try: + assert await client._check_optional_get_endpoint(path) == ( + "available", + {"status": {"x": 1}}, + ) + assert cache_key in client._endpoint_availability + assert cache_key in client._endpoint_checked_at + + with caplog.at_level(logging.WARNING): + assert await client._check_optional_get_endpoint(path) == ("missing", {}) + + assert cache_key not in client._endpoint_availability + assert cache_key not in client._endpoint_checked_at + assert cache_key in client._optional_endpoint_missing_pending_confirmation + client._is_core_firmware_endpoint_healthy.assert_awaited_once_with() + assert not any( + "Transient optional GET endpoint failure" in record.message for record in caplog.records + ) + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_get_endpoint_rechecks_pending_and_caches_negative_for_ttl( + make_client: MakeClientFactory, +) -> None: + """Verify pending optional miss gets confirmed by firmware status once then cached.""" + client, _session = make_mock_session_client(make_client) + path = "/api/speedtest/service/showstat" + cache_key = ("get", path) + client._get_optional = AsyncMock(side_effect=[("missing", {}), ("missing", {})]) + client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=True) + + try: + first_state, first_payload = await client._check_optional_get_endpoint(path) + second_state, second_payload = await client._check_optional_get_endpoint(path) + third_state, third_payload = await client._check_optional_get_endpoint(path) + + assert first_state == "missing" + assert first_payload == {} + assert second_state == "missing" + assert second_payload == {} + assert third_state == "missing" + assert third_payload == {} + assert client._get_optional.await_count == 2 + assert client._is_core_firmware_endpoint_healthy.await_count == 2 + assert client._endpoint_availability[cache_key] is False + assert cache_key in client._endpoint_checked_at + + # second pending confirmation should apply the optional negative-cache window + checked_age = monotonic() - client._endpoint_checked_at[cache_key] + assert checked_age >= 0 + assert checked_age < DEFAULT_NEGATIVE_CACHE_TTL_SECONDS + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_get_endpoint_stale_negative_recovers_after_ttl_expiry( + monkeypatch: pytest.MonkeyPatch, make_client: MakeClientFactory +) -> None: + """Verify stale optional misses are retried and can recover to available.""" + client, _session = make_mock_session_client(make_client) + path = "/api/speedtest/service/showrecent" + cache_key = ("get", path) + client._endpoint_availability[cache_key] = False + times = iter([1000.0, 1001.0]) + monkeypatch.setattr( + "aiopnsense.client_endpoint.monotonic", + lambda: next(times), + ) + client._endpoint_checked_at[cache_key] = 1000.0 - (DEFAULT_NEGATIVE_CACHE_TTL_SECONDS + 1) + client._get_optional = AsyncMock(return_value=("available", {"status": "recovered"})) + + try: + state, payload = await client._check_optional_get_endpoint(path) + + assert state == "available" + assert payload == {"status": "recovered"} + assert client._endpoint_availability[cache_key] is True + assert client._endpoint_checked_at[cache_key] == 1001.0 + assert client._endpoint_checked_at[cache_key] != 1000.0 - ( + DEFAULT_NEGATIVE_CACHE_TTL_SECONDS + 1 + ) + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_get_endpoint_stale_negative_renews_with_one_probe( + monkeypatch: pytest.MonkeyPatch, make_client: MakeClientFactory +) -> None: + """A persistently missing endpoint costs one optional probe per negative TTL.""" + client, _session = make_mock_session_client(make_client) + path = "/api/speedtest/service/showrecent" + cache_key = ("get", path) + client._endpoint_availability[cache_key] = False + client._endpoint_checked_at[cache_key] = 1000.0 - (DEFAULT_NEGATIVE_CACHE_TTL_SECONDS + 1) + times = iter([1000.0, 1001.0]) + monkeypatch.setattr("aiopnsense.client_endpoint.monotonic", lambda: next(times)) + client._get_optional = AsyncMock(return_value=("missing", {})) + client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=True) + + try: + assert await client._check_optional_get_endpoint(path) == ("missing", {}) + assert client._endpoint_availability[cache_key] is False + assert client._endpoint_checked_at[cache_key] == 1001.0 + assert cache_key not in client._optional_endpoint_missing_pending_confirmation + client._get_optional.assert_awaited_once_with(path) + client._is_core_firmware_endpoint_healthy.assert_awaited_once_with() + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_get_endpoint_force_refresh_allows_recovery_before_confirmation( + make_client: MakeClientFactory, +) -> None: + """Force refresh should prioritize live optional probe over pending confirmation.""" + client, _session = make_mock_session_client(make_client) + path = "/api/speedtest/service/showstat" + cache_key = ("get", path) + client._get_optional = AsyncMock(return_value=("available", {"samples": 1})) + client._endpoint_availability[cache_key] = False + client._endpoint_checked_at[cache_key] = 100.0 + client._optional_endpoint_missing_pending_confirmation.add(cache_key) + + try: + state, payload = await client._check_optional_get_endpoint(path, force_refresh=True) + + assert state == "available" + assert payload == {"samples": 1} + assert cache_key in client._endpoint_availability + assert client._endpoint_availability[cache_key] is True + assert cache_key not in client._optional_endpoint_missing_pending_confirmation + assert client._get_optional.await_count == 1 + finally: + await client.async_close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("state", "core_healthy", "expected_state", "expected_cached"), + [ + ("missing", True, "missing", False), + ("missing", False, "unavailable", None), + ("unavailable", True, "unavailable", None), + ], +) +async def test_check_optional_get_endpoint_force_refresh_preserves_confirmation_contract( + state: str, + core_healthy: bool, + expected_state: str, + expected_cached: bool | None, + make_client: MakeClientFactory, +) -> None: + """Force refresh bypasses cache freshness without bypassing confirmation.""" + client, _session = make_mock_session_client(make_client) + path = "/api/speedtest/service/showrecent" + cache_key = ("get", path) + client._optional_endpoint_missing_pending_confirmation.add(cache_key) + client._get_optional = AsyncMock(return_value=(state, {})) + client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=core_healthy) + + try: + result_state, payload = await client._check_optional_get_endpoint(path, force_refresh=True) + + assert result_state == expected_state + assert payload == {} + if expected_cached is None: + assert cache_key not in client._endpoint_availability + assert cache_key in client._optional_endpoint_missing_pending_confirmation + else: + assert client._endpoint_availability[cache_key] is expected_cached + assert cache_key not in client._optional_endpoint_missing_pending_confirmation + if state == "missing": + client._is_core_firmware_endpoint_healthy.assert_awaited_once_with() + else: + client._is_core_firmware_endpoint_healthy.assert_not_awaited() + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_get_endpoint_pending_confirmation_does_not_cache_when_core_unavailable( + make_client: MakeClientFactory, +) -> None: + """Core confirmation failure keeps optional cache uncommitted and pending.""" + client, _session = make_mock_session_client(make_client) + path = "/api/nut/diagnostics/upsstatus" + cache_key = ("get", path) + client._get_optional = AsyncMock(return_value=("missing", {})) + client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=False) + + try: + first_state, first_payload = await client._check_optional_get_endpoint(path) + assert first_state == "unavailable" + assert cache_key in client._optional_endpoint_missing_pending_confirmation + + second_state, second_payload = await client._check_optional_get_endpoint(path) + assert second_state == "unavailable" + assert second_payload == {} + assert cache_key in client._optional_endpoint_missing_pending_confirmation + assert cache_key not in client._endpoint_availability + assert cache_key not in client._endpoint_checked_at + assert first_payload == {} + assert second_payload == {} + assert client._get_optional.await_count == 2 + assert client._is_core_firmware_endpoint_healthy.await_count == 2 + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_get_endpoint_recovers_before_core_confirmation( + make_client: MakeClientFactory, +) -> None: + """A recovered optional route wins before a pending miss is confirmed.""" + client, _session = make_mock_session_client(make_client) + path = "/api/speedtest/service/showrecent" + cache_key = ("get", path) + client._get_optional = AsyncMock( + side_effect=[("missing", {}), ("available", {"recovered": True})] + ) + client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=True) + + try: + assert await client._check_optional_get_endpoint(path) == ("missing", {}) + assert await client._check_optional_get_endpoint(path) == ( + "available", + {"recovered": True}, + ) + assert client._endpoint_availability[cache_key] is True + assert cache_key not in client._optional_endpoint_missing_pending_confirmation + client._is_core_firmware_endpoint_healthy.assert_awaited_once_with() finally: await client.async_close() +@pytest.mark.asyncio +async def test_optional_endpoint_core_health_uses_fresh_raw_firmware_request( + make_client: MakeClientFactory, +) -> None: + """Core confirmation bypasses endpoint cache and the request queue.""" + client, session = make_mock_session_client(make_client) + requested_urls: list[str] = [] + + def get(url: str, **_kwargs: Any) -> FakeResponse: + """Capture and satisfy the direct firmware health request.""" + requested_urls.append(url) + return FakeResponse(status=200, ok=True) + + session.get = get + client._endpoint_availability[("get", "/api/core/firmware/status")] = False + client._endpoint_checked_at[("get", "/api/core/firmware/status")] = monotonic() + query_count = client._rest_api_query_count + + try: + assert await client._is_core_firmware_endpoint_healthy() is True + assert requested_urls == [f"{client._url}/api/core/firmware/status"] + assert client._rest_api_query_count == query_count + 1 + assert client._endpoint_availability[("get", "/api/core/firmware/status")] is False + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_optional_endpoint_calls_are_serialized_per_cache_key( + make_client: MakeClientFactory, +) -> None: + """Concurrent calls for one optional route never overlap requests.""" + client, _session = make_mock_session_client(make_client) + path = "/api/nut/diagnostics/upsstatus" + active = 0 + maximum_active = 0 + + async def optional_get(_path: str) -> tuple[str, object]: + """Track concurrent entry into the optional transport boundary.""" + nonlocal active, maximum_active + active += 1 + maximum_active = max(maximum_active, active) + await asyncio.sleep(0) + active -= 1 + return "available", {"status": {"ups.status": "OL"}} + + client._get_optional = AsyncMock(side_effect=optional_get) + try: + results = await asyncio.gather( + client._check_optional_get_endpoint(path), + client._check_optional_get_endpoint(path), + ) + assert maximum_active == 1 + assert all(state == "available" for state, _payload in results) + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_get_endpoint_cached_positive_not_mutated_by_transient_states( + make_client: MakeClientFactory, +) -> None: + """Verify transient optional states do not erase prior positive cache.""" + client, _session = make_mock_session_client(make_client) + path = "/api/speedtest/service/showrecent" + cache_key = ("get", path) + client._endpoint_availability[cache_key] = True + client._endpoint_checked_at[cache_key] = monotonic() + + client._get_optional = AsyncMock(return_value=("unavailable", {})) + + try: + state, payload = await client._check_optional_get_endpoint(path) + + assert state == "unavailable" + assert payload == {} + assert client._endpoint_availability[cache_key] is True + assert cache_key in client._endpoint_checked_at + assert cache_key not in client._optional_endpoint_missing_pending_confirmation + finally: + await client.async_close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("state", ["available", "malformed"]) +async def test_check_optional_post_endpoint_refreshes_positive_exact_cache_key( + state: str, + make_client: MakeClientFactory, +) -> None: + """Read-only SMART POST success refreshes its explicit probe cache key.""" + client, _session = make_mock_session_client(make_client) + client._post_optional = AsyncMock(return_value=(state, {"devices": []})) + cache_key = ("post", "/api/smart/service/list") + try: + result_state, payload = await client._check_optional_post_endpoint( + "/api/smart/service/list/1", + cache_path="/api/smart/service/list", + ) + assert result_state == state + assert payload == {"devices": []} + assert client._endpoint_availability[cache_key] is True + assert ("post", "/api/smart/service/list/1") not in client._endpoint_availability + client._post_optional.assert_awaited_once_with("/api/smart/service/list/1", None) + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_post_endpoint_confirms_second_404_with_core_health( + make_client: MakeClientFactory, +) -> None: + """A second read-only POST 404 plus healthy core stores a short negative.""" + client, _session = make_mock_session_client(make_client) + cache_key = ("post", "/api/smart/service/info") + client._post_optional = AsyncMock(return_value=("missing", {})) + client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=True) + try: + assert await client._check_optional_post_endpoint( + "/api/smart/service/info", payload={"device": "ada0"} + ) == ("missing", {}) + assert cache_key in client._optional_endpoint_missing_pending_confirmation + + assert await client._check_optional_post_endpoint( + "/api/smart/service/info", payload={"device": "ada0"} + ) == ("missing", {}) + assert client._endpoint_availability[cache_key] is False + assert cache_key not in client._optional_endpoint_missing_pending_confirmation + assert client._post_optional.await_count == 2 + assert client._is_core_firmware_endpoint_healthy.await_count == 2 + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_post_endpoint_stale_negative_renews_with_one_probe( + monkeypatch: pytest.MonkeyPatch, make_client: MakeClientFactory +) -> None: + """An expired SMART absence is renewed with one read-only POST probe.""" + client, _session = make_mock_session_client(make_client) + path = "/api/smart/service/info" + cache_key = ("post", path) + client._endpoint_availability[cache_key] = False + client._endpoint_checked_at[cache_key] = 1000.0 - (DEFAULT_NEGATIVE_CACHE_TTL_SECONDS + 1) + times = iter([1000.0, 1001.0]) + monkeypatch.setattr("aiopnsense.client_endpoint.monotonic", lambda: next(times)) + client._post_optional = AsyncMock(return_value=("missing", {})) + client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=True) + payload = {"device": "ada0"} + + try: + assert await client._check_optional_post_endpoint(path, payload=payload) == ( + "missing", + {}, + ) + assert client._endpoint_availability[cache_key] is False + assert client._endpoint_checked_at[cache_key] == 1001.0 + assert cache_key not in client._optional_endpoint_missing_pending_confirmation + client._post_optional.assert_awaited_once_with(path, payload) + client._is_core_firmware_endpoint_healthy.assert_awaited_once_with() + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_post_endpoint_transient_failure_preserves_positive( + make_client: MakeClientFactory, +) -> None: + """A non-404 SMART failure must not mutate the positive observation.""" + client, _session = make_mock_session_client(make_client) + cache_key = ("post", "/api/smart/service/info") + client._endpoint_availability[cache_key] = True + checked_at = monotonic() + client._endpoint_checked_at[cache_key] = checked_at + client._post_optional = AsyncMock(return_value=("unavailable", {})) + try: + assert await client._check_optional_post_endpoint("/api/smart/service/info") == ( + "unavailable", + {}, + ) + assert client._endpoint_availability[cache_key] is True + assert client._endpoint_checked_at[cache_key] == checked_at + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_post_endpoint_rejects_unregistered_mapping( + make_client: MakeClientFactory, +) -> None: + """Derived request paths cannot update unrelated endpoint cache keys.""" + client, _session = make_mock_session_client(make_client) + client._post_optional = AsyncMock() + try: + assert await client._check_optional_post_endpoint( + "/api/smart/service/list/1", + cache_path="/api/smart/service/info", + ) == ("unavailable", {}) + client._post_optional.assert_not_awaited() + finally: + await client.async_close() + + +@pytest.mark.parametrize( + "opts", + [ + {"endpoint_positive_cache_ttl_seconds": 0}, + {"endpoint_positive_cache_ttl_seconds": True}, + {"endpoint_negative_cache_ttl_seconds": -1}, + {"endpoint_negative_cache_ttl_seconds": False}, + ], +) +def test_endpoint_cache_ttls_require_positive_integers( + make_client: MakeClientFactory, + opts: dict[str, object], +) -> None: + """Endpoint TTL overrides reject booleans and non-positive integers.""" + with pytest.raises(OPNsenseInvalidArgument, match="must be a positive integer"): + make_client(opts=opts) + + +def test_endpoint_cache_ttls_are_independently_configurable( + make_client: MakeClientFactory, +) -> None: + """Positive and confirmed-negative cache windows accept separate overrides.""" + client = make_client( + opts={ + "endpoint_positive_cache_ttl_seconds": 600, + "endpoint_negative_cache_ttl_seconds": 30, + } + ) + assert client._endpoint_cache_ttl_seconds == 600 + assert client._endpoint_negative_cache_ttl_seconds == 30 + + @pytest.mark.parametrize( "path", [ @@ -657,7 +1187,7 @@ def _post(*args: object, **kwargs: object) -> FakeResponse: path = "/api/core/firmware/changelog/26.1.1" assert await client._is_post_endpoint_available(path) is True assert calls == 1 - assert f"post:{path}" in client._endpoint_checked_at + assert ("post", path) in client._endpoint_checked_at finally: await client.async_close() @@ -712,12 +1242,13 @@ def _get(*args: Any, **kwargs: Any) -> Any: session.get = _get try: path = "/api/core/firmware/status" + cache_key = ("get", path) with pytest.raises(expected_exception): await client.validate() assert calls == 1 assert client._throw_errors is False - assert path not in client._endpoint_checked_at - assert path not in client._endpoint_availability + assert cache_key not in client._endpoint_checked_at + assert cache_key not in client._endpoint_availability finally: await client.async_close() diff --git a/tests/test_client_queue.py b/tests/test_client_queue.py index 2eb732f..bbf2664 100644 --- a/tests/test_client_queue.py +++ b/tests/test_client_queue.py @@ -177,6 +177,8 @@ async def fake_do_get(path: Any, caller: str = "x", response_format: str = "json client._do_get = AsyncMock(side_effect=fake_do_get) client._do_post = AsyncMock(return_value={"p": 2}) client._do_get_from_stream = AsyncMock(return_value={"s": 3}) + client._do_optional_get = AsyncMock(return_value=("available", {"o": 4})) + client._do_optional_post = AsyncMock(return_value=("available", {"op": 5})) # replace request queue with a real one q: asyncio.Queue = asyncio.Queue() @@ -190,24 +192,34 @@ async def fake_do_get(path: Any, caller: str = "x", response_format: str = "json fut_get_text = loop.create_future() fut_post = loop.create_future() fut_stream = loop.create_future() + fut_optional = loop.create_future() + fut_optional_post = loop.create_future() await q.put(("get", "/g", None, fut_get, "t")) await q.put(("get_text", "/gt", None, fut_get_text, "t")) await q.put(("post", "/p", {"x": 1}, fut_post, "t")) await q.put(("get_from_stream", "/s", None, fut_stream, "t")) + await q.put(("optional_get", "/o", None, fut_optional, "t")) + await q.put(("optional_post", "/op", {"read": True}, fut_optional_post, "t")) res1 = await asyncio.wait_for(fut_get, timeout=2) res_text = await asyncio.wait_for(fut_get_text, timeout=2) res2 = await asyncio.wait_for(fut_post, timeout=2) res3 = await asyncio.wait_for(fut_stream, timeout=2) + res4 = await asyncio.wait_for(fut_optional, timeout=2) + res5 = await asyncio.wait_for(fut_optional_post, timeout=2) assert res1 == {"g": 1} assert res_text == "text" assert res2 == {"p": 2} assert res3 == {"s": 3} + assert res4 == ("available", {"o": 4}) + assert res5 == ("available", {"op": 5}) assert client._do_get.await_count == 2 client._do_get.assert_any_await("/g", "t") client._do_get.assert_any_await("/gt", "t", response_format="text") + client._do_optional_get.assert_awaited_once_with("/o", "t") + client._do_optional_post.assert_awaited_once_with("/op", {"read": True}, "t") # cancel the processor task task.cancel() diff --git a/tests/test_client_transport.py b/tests/test_client_transport.py index 1d9821c..5f380af 100644 --- a/tests/test_client_transport.py +++ b/tests/test_client_transport.py @@ -229,12 +229,100 @@ async def test_do_get_post_error_initial_behavior( await client.async_close() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("status", "ok", "expected"), + [ + (200, True, ("available", {"value": 1})), + (404, False, ("missing", {})), + (403, False, ("unavailable", {})), + (429, False, ("unavailable", {})), + (500, False, ("unavailable", {})), + ], +) +async def test_do_optional_get_returns_tri_state_envelope( + status: int, + ok: bool, + expected: tuple[str, object], + make_client: MakeClientFactory, +) -> None: + """Optional GET preserves the distinction between 404 and router failures.""" + client, session = make_mock_session_client(make_client) + session.get = lambda *_args, **_kwargs: FakeResponse( + status=status, + reason="test", + ok=ok, + json_payload={"value": 1}, + ) + try: + assert await client._do_optional_get("/api/optional", caller="test") == expected + finally: + await client.async_close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("status", "ok", "expected"), + [ + (200, True, ("available", {"value": 1})), + (404, False, ("missing", {})), + (403, False, ("unavailable", {})), + (429, False, ("unavailable", {})), + (500, False, ("unavailable", {})), + ], +) +async def test_do_optional_post_returns_tri_state_envelope( + status: int, + ok: bool, + expected: tuple[str, object], + make_client: MakeClientFactory, +) -> None: + """Optional read-only POST distinguishes route absence from router failures.""" + client, session = make_mock_session_client(make_client) + session.post = lambda *_args, **_kwargs: FakeResponse( + status=status, + reason="test", + ok=ok, + json_payload={"value": 1}, + ) + try: + assert ( + await client._do_optional_post("/api/optional", {"read": True}, caller="test") + == expected + ) + finally: + await client.async_close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["get", "post"]) +async def test_optional_transport_classifies_malformed_success( + method: str, + make_client: MakeClientFactory, +) -> None: + """Malformed JSON remains a successful route observation with no payload.""" + client, session = make_mock_session_client(make_client) + response = FakeResponse(status=200, ok=True) + response.json = AsyncMock(side_effect=ValueError("invalid JSON")) + setattr(session, method, lambda *_args, **_kwargs: response) + try: + if method == "get": + result = await client._do_optional_get("/api/optional", caller="test") + else: + result = await client._do_optional_post("/api/optional", caller="test") + assert result == ("malformed", {}) + finally: + await client.async_close() + + @pytest.mark.parametrize( ("method_name", "session_method", "args", "kwargs"), [ ("_do_get_from_stream", "get", ("/stream",), {"caller": "tst"}), ("_stream_json_events", "get", ("/stream",), {}), ("_do_get", "get", ("/api/x",), {"caller": "tst"}), + ("_do_optional_get", "get", ("/api/x",), {"caller": "tst"}), + ("_do_optional_post", "post", ("/api/x",), {"caller": "tst"}), ("_do_get", "get", ("/api/x",), {"caller": "tst", "response_format": "text"}), ("_do_post", "post", ("/api/x",), {"payload": {}, "caller": "tst"}), ], diff --git a/tests/test_nut.py b/tests/test_nut.py index 29a858b..5436d7a 100644 --- a/tests/test_nut.py +++ b/tests/test_nut.py @@ -27,15 +27,17 @@ async def test_get_nut_ups_status_preserves_nested_status_payload( """ client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock( - return_value={ - "status": { - "ups.status": "OL", - "battery.charge": "100", - "ups.load": "12", - } - } + client._check_optional_get_endpoint = AsyncMock( + return_value=( + "available", + { + "status": { + "ups.status": "OL", + "battery.charge": "100", + "ups.load": "12", + } + }, + ) ) nut_status = await client.get_nut_ups_status() @@ -47,8 +49,9 @@ async def test_get_nut_ups_status_preserves_nested_status_payload( "ups.load": "12", } } - client._is_get_endpoint_available.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") - client._safe_dict_get.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/nut/diagnostics/upsstatus" + ) finally: await client.async_close() @@ -65,16 +68,18 @@ async def test_get_nut_ups_status_parses_raw_status_response(make_client: Client """ client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock( - return_value={ - "response": ( - "battery.charge: 100\n" - "ups.status: OL\n" - "input.L1-N.voltage: 120\n" - "input.L1-L2.voltage: 240\n" - ) - } + client._check_optional_get_endpoint = AsyncMock( + return_value=( + "available", + { + "response": ( + "battery.charge: 100\n" + "ups.status: OL\n" + "input.L1-N.voltage: 120\n" + "input.L1-L2.voltage: 240\n" + ) + }, + ) ) nut_status = await client.get_nut_ups_status() @@ -93,8 +98,9 @@ async def test_get_nut_ups_status_parses_raw_status_response(make_client: Client "input.L1-L2.voltage": "240", }, } - client._is_get_endpoint_available.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") - client._safe_dict_get.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/nut/diagnostics/upsstatus" + ) finally: await client.async_close() @@ -113,13 +119,15 @@ async def test_get_nut_ups_status_prefers_mapped_status_when_available( """ client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock( - return_value={ - "status": {"ups.status": "OL"}, - "request_id": "abc-123", - "response": "ups.status: OB", - } + client._check_optional_get_endpoint = AsyncMock( + return_value=( + "available", + { + "status": {"ups.status": "OL"}, + "request_id": "abc-123", + "response": "ups.status: OB", + }, + ) ) nut_status = await client.get_nut_ups_status() @@ -129,8 +137,9 @@ async def test_get_nut_ups_status_prefers_mapped_status_when_available( "request_id": "abc-123", "response": "ups.status: OB", } - client._is_get_endpoint_available.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") - client._safe_dict_get.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/nut/diagnostics/upsstatus" + ) finally: await client.async_close() @@ -149,8 +158,9 @@ async def test_get_nut_ups_status_uses_raw_response_when_mapped_status_is_empty( """ client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock(return_value={"status": {}, "response": "ups.status: OL"}) + client._check_optional_get_endpoint = AsyncMock( + return_value=("available", {"status": {}, "response": "ups.status: OL"}) + ) nut_status = await client.get_nut_ups_status() @@ -158,8 +168,9 @@ async def test_get_nut_ups_status_uses_raw_response_when_mapped_status_is_empty( "response": "ups.status: OL", "status": {"ups.status": "OL"}, } - client._is_get_endpoint_available.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") - client._safe_dict_get.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/nut/diagnostics/upsstatus" + ) finally: await client.async_close() @@ -178,20 +189,22 @@ async def test_get_nut_ups_status_parses_colon_in_value_and_ignores_invalid_line """ client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock( - return_value={ - "response": "\n".join( - [ - "battery.charge: 100", - " ", - "Error: UPS unavailable", - "ups.message: on battery: replace battery", - "this-line-is-invalid", - "ups.load: 12", - ] - ) - } + client._check_optional_get_endpoint = AsyncMock( + return_value=( + "available", + { + "response": "\n".join( + [ + "battery.charge: 100", + " ", + "Error: UPS unavailable", + "ups.message: on battery: replace battery", + "this-line-is-invalid", + "ups.load: 12", + ] + ) + }, + ) ) nut_status = await client.get_nut_ups_status() @@ -213,8 +226,9 @@ async def test_get_nut_ups_status_parses_colon_in_value_and_ignores_invalid_line "ups.load": "12", }, } - client._is_get_endpoint_available.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") - client._safe_dict_get.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/nut/diagnostics/upsstatus" + ) finally: await client.async_close() @@ -254,8 +268,9 @@ async def test_get_nut_ups_status_handles_invalid_payloads( """ client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock(return_value=response_payload) + client._check_optional_get_endpoint = AsyncMock( + return_value=("available", response_payload) + ) nut_status = await client.get_nut_ups_status() @@ -264,15 +279,18 @@ async def test_get_nut_ups_status_handles_invalid_payloads( assert nut_status == expected if expect_no_status: assert "status" not in nut_status - client._is_get_endpoint_available.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") - client._safe_dict_get.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/nut/diagnostics/upsstatus" + ) finally: await client.async_close() +@pytest.mark.parametrize("state", ["missing", "unavailable"]) @pytest.mark.asyncio async def test_get_nut_ups_status_returns_empty_dict_when_endpoint_unavailable( make_client: ClientType, + state: str, ) -> None: """NUT UPS status queries should fail closed when the endpoint is unavailable. @@ -284,14 +302,14 @@ async def test_get_nut_ups_status_returns_empty_dict_when_endpoint_unavailable( """ client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=False) - client._safe_dict_get = AsyncMock(return_value={}) + client._check_optional_get_endpoint = AsyncMock(return_value=(state, {})) nut_status = await client.get_nut_ups_status() assert nut_status == {} - client._is_get_endpoint_available.assert_awaited_once_with("/api/nut/diagnostics/upsstatus") - client._safe_dict_get.assert_not_awaited() + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/nut/diagnostics/upsstatus" + ) finally: await client.async_close() diff --git a/tests/test_smart.py b/tests/test_smart.py index c970383..7c673c4 100644 --- a/tests/test_smart.py +++ b/tests/test_smart.py @@ -23,14 +23,16 @@ async def test_get_smart_returns_device_rows(make_client: ClientType) -> None: """ client, _session = make_mock_session_client(make_client) try: - client._is_post_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_post = AsyncMock( - return_value={ - "devices": [ - {"ident": "nvme0", "device": "nvme0", "status": "PASSED"}, - {"ident": "ada0", "device": "ada0", "status": "FAILED"}, - ] - } + client._check_optional_post_endpoint = AsyncMock( + return_value=( + "available", + { + "devices": [ + {"ident": "nvme0", "device": "nvme0", "status": "PASSED"}, + {"ident": "ada0", "device": "ada0", "status": "FAILED"}, + ] + }, + ) ) smart_devices = await client.get_smart() @@ -39,8 +41,9 @@ async def test_get_smart_returns_device_rows(make_client: ClientType) -> None: {"ident": "nvme0", "device": "nvme0", "status": "PASSED"}, {"ident": "ada0", "device": "ada0", "status": "FAILED"}, ] - client._is_post_endpoint_available.assert_awaited_once_with("/api/smart/service/list") - client._safe_dict_post.assert_awaited_once_with("/api/smart/service/list/1") + client._check_optional_post_endpoint.assert_awaited_once_with( + "/api/smart/service/list/1", cache_path="/api/smart/service/list" + ) finally: await client.async_close() @@ -57,21 +60,24 @@ async def test_get_smart_skips_devices_with_blank_ident(make_client: ClientType) """ client, _session = make_mock_session_client(make_client) try: - client._is_post_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_post = AsyncMock( - return_value={ - "devices": [ - {"ident": "", "device": "nvme0", "status": "PASSED"}, - {"ident": "nvme0", "device": "nvme0", "status": "PASSED"}, - ] - } + client._check_optional_post_endpoint = AsyncMock( + return_value=( + "available", + { + "devices": [ + {"ident": "", "device": "nvme0", "status": "PASSED"}, + {"ident": "nvme0", "device": "nvme0", "status": "PASSED"}, + ] + }, + ) ) smart_devices = await client.get_smart() assert smart_devices == [{"ident": "nvme0", "device": "nvme0", "status": "PASSED"}] - client._is_post_endpoint_available.assert_awaited_once_with("/api/smart/service/list") - client._safe_dict_post.assert_awaited_once_with("/api/smart/service/list/1") + client._check_optional_post_endpoint.assert_awaited_once_with( + "/api/smart/service/list/1", cache_path="/api/smart/service/list" + ) finally: await client.async_close() @@ -92,14 +98,16 @@ async def test_get_smart_logs_and_skips_non_mapping_rows( """ client, _session = make_mock_session_client(make_client) try: - client._is_post_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_post = AsyncMock( - return_value={ - "devices": [ - "unexpected-string-row", - {"ident": "nvme0", "device": "nvme0", "status": "PASSED"}, - ] - } + client._check_optional_post_endpoint = AsyncMock( + return_value=( + "available", + { + "devices": [ + "unexpected-string-row", + {"ident": "nvme0", "device": "nvme0", "status": "PASSED"}, + ] + }, + ) ) with caplog.at_level("DEBUG"): @@ -110,8 +118,9 @@ async def test_get_smart_logs_and_skips_non_mapping_rows( "Discarding SMART device row because item is not a mapping: 'unexpected-string-row'" in caplog.text ) - client._is_post_endpoint_available.assert_awaited_once_with("/api/smart/service/list") - client._safe_dict_post.assert_awaited_once_with("/api/smart/service/list/1") + client._check_optional_post_endpoint.assert_awaited_once_with( + "/api/smart/service/list/1", cache_path="/api/smart/service/list" + ) finally: await client.async_close() @@ -128,14 +137,16 @@ async def test_get_smart_returns_empty_list_for_non_list_payload(make_client: Cl """ client, _session = make_mock_session_client(make_client) try: - client._is_post_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_post = AsyncMock(return_value={"devices": "ignored"}) + client._check_optional_post_endpoint = AsyncMock( + return_value=("available", {"devices": "ignored"}) + ) smart_devices = await client.get_smart() assert smart_devices == [] - client._is_post_endpoint_available.assert_awaited_once_with("/api/smart/service/list") - client._safe_dict_post.assert_awaited_once_with("/api/smart/service/list/1") + client._check_optional_post_endpoint.assert_awaited_once_with( + "/api/smart/service/list/1", cache_path="/api/smart/service/list" + ) finally: await client.async_close() @@ -148,11 +159,13 @@ async def test_get_smart_returns_empty_list_for_non_list_payload(make_client: Cl ("info", "/api/smart/service/info", {}), ], ) +@pytest.mark.parametrize("availability_status", ["missing", "unavailable", "malformed"]) async def test_smart_fails_closed_when_endpoint_is_unavailable( make_client: ClientType, operation: str, endpoint: str, expected: object, + availability_status: str, ) -> None: """SMART POST operations should fail closed when endpoint availability checks fail. @@ -161,14 +174,14 @@ async def test_smart_fails_closed_when_endpoint_is_unavailable( operation (str): SMART operation under test. endpoint (str): Expected endpoint for availability check. expected (object): Expected fail-closed payload. + availability_status (str): Simulated optional endpoint availability state. Returns: None: This test validates fail-closed behavior for SMART POST operations. """ client, _session = make_mock_session_client(make_client) try: - client._is_post_endpoint_available = AsyncMock(return_value=False) - client._safe_dict_post = AsyncMock(return_value={}) + client._check_optional_post_endpoint = AsyncMock(return_value=(availability_status, {})) if operation == "list": got = await client.get_smart() @@ -176,8 +189,15 @@ async def test_smart_fails_closed_when_endpoint_is_unavailable( got = await client.get_smart_info("nvme0") assert got == expected - client._is_post_endpoint_available.assert_awaited_once_with(endpoint) - client._safe_dict_post.assert_not_awaited() + if operation == "list": + client._check_optional_post_endpoint.assert_awaited_once_with( + "/api/smart/service/list/1", cache_path="/api/smart/service/list" + ) + else: + client._check_optional_post_endpoint.assert_awaited_once_with( + "/api/smart/service/info", + payload={"device": "nvme0", "type": "a", "json": True}, + ) finally: await client.async_close() @@ -197,16 +217,19 @@ async def test_get_smart_does_not_probe_post_only_endpoint(make_client: ClientTy client._is_get_endpoint_available = AsyncMock( side_effect=AssertionError("GET probe should not run") ) - client._is_post_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_post = AsyncMock( - return_value={"devices": [{"ident": "nvme0", "device": "nvme0", "status": "PASSED"}]} + client._check_optional_post_endpoint = AsyncMock( + return_value=( + "available", + {"devices": [{"ident": "nvme0", "device": "nvme0", "status": "PASSED"}]}, + ) ) assert await client.get_smart() == [ {"ident": "nvme0", "device": "nvme0", "status": "PASSED"} ] - client._is_post_endpoint_available.assert_awaited_once_with("/api/smart/service/list") - client._safe_dict_post.assert_awaited_once_with("/api/smart/service/list/1") + client._check_optional_post_endpoint.assert_awaited_once_with( + "/api/smart/service/list/1", cache_path="/api/smart/service/list" + ) finally: await client.async_close() @@ -223,18 +246,16 @@ async def test_get_smart_info_returns_json_output(make_client: ClientType) -> No """ client, _session = make_mock_session_client(make_client) try: - client._is_post_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_post = AsyncMock( - return_value={"output": {"smart_status": "PASSED", "temperature": 35}} + client._check_optional_post_endpoint = AsyncMock( + return_value=("available", {"output": {"smart_status": "PASSED", "temperature": 35}}) ) smart_info = await client.get_smart_info("nvme0") assert smart_info == {"smart_status": "PASSED", "temperature": 35} - client._is_post_endpoint_available.assert_awaited_once_with("/api/smart/service/info") - client._safe_dict_post.assert_awaited_once_with( + client._check_optional_post_endpoint.assert_awaited_once_with( "/api/smart/service/info", - {"device": "nvme0", "type": "a", "json": True}, + payload={"device": "nvme0", "type": "a", "json": True}, ) finally: await client.async_close() @@ -252,16 +273,16 @@ async def test_get_smart_info_wraps_non_mapping_output(make_client: ClientType) """ client, _session = make_mock_session_client(make_client) try: - client._is_post_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_post = AsyncMock(return_value={"output": ["line1", "line2"]}) + client._check_optional_post_endpoint = AsyncMock( + return_value=("available", {"output": ["line1", "line2"]}) + ) smart_info = await client.get_smart_info("nvme0", info_type="H") assert smart_info == {"output": ["line1", "line2"]} - client._is_post_endpoint_available.assert_awaited_once_with("/api/smart/service/info") - client._safe_dict_post.assert_awaited_once_with( + client._check_optional_post_endpoint.assert_awaited_once_with( "/api/smart/service/info", - {"device": "nvme0", "type": "H", "json": True}, + payload={"device": "nvme0", "type": "H", "json": True}, ) finally: await client.async_close() @@ -282,14 +303,14 @@ async def test_get_smart_info_does_not_probe_post_only_endpoint(make_client: Cli client._is_get_endpoint_available = AsyncMock( side_effect=AssertionError("GET probe should not run") ) - client._is_post_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_post = AsyncMock(return_value={"output": {"smart_status": "PASSED"}}) + client._check_optional_post_endpoint = AsyncMock( + return_value=("available", {"output": {"smart_status": "PASSED"}}) + ) assert await client.get_smart_info("nvme0") == {"smart_status": "PASSED"} - client._is_post_endpoint_available.assert_awaited_once_with("/api/smart/service/info") - client._safe_dict_post.assert_awaited_once_with( + client._check_optional_post_endpoint.assert_awaited_once_with( "/api/smart/service/info", - {"device": "nvme0", "type": "a", "json": True}, + payload={"device": "nvme0", "type": "a", "json": True}, ) finally: await client.async_close() @@ -309,12 +330,12 @@ async def test_get_smart_fails_closed_when_list_endpoint_unavailable( """ client, _session = make_mock_session_client(make_client) try: - client._is_post_endpoint_available = AsyncMock(return_value=False) - client._safe_dict_post = AsyncMock(return_value={}) + client._check_optional_post_endpoint = AsyncMock(return_value=("unavailable", {})) assert await client.get_smart() == [] - client._is_post_endpoint_available.assert_awaited_once_with("/api/smart/service/list") - client._safe_dict_post.assert_not_awaited() + client._check_optional_post_endpoint.assert_awaited_once_with( + "/api/smart/service/list/1", cache_path="/api/smart/service/list" + ) finally: await client.async_close() diff --git a/tests/test_speedtest.py b/tests/test_speedtest.py index 34f08b1..e8ee0c1 100644 --- a/tests/test_speedtest.py +++ b/tests/test_speedtest.py @@ -16,43 +16,66 @@ async def test_get_speedtest_skips_calls_when_endpoint_missing(make_client) -> N """get_speedtest should skip speedtest API calls when endpoint is unavailable.""" client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=False) - client._safe_dict_get = AsyncMock() + client._check_optional_get_endpoint = AsyncMock(return_value=("missing", {})) result = await client.get_speedtest() assert result == {"available": False} - client._safe_dict_get.assert_not_awaited() - client._is_get_endpoint_available.assert_awaited_once_with( + client._check_optional_get_endpoint.assert_awaited_once_with( "/api/speedtest/service/showrecent" ) finally: await client.async_close() +@pytest.mark.asyncio +async def test_get_speedtest_preserves_plugin_availability_during_transient_failure( + make_client, +) -> None: + """A transient router failure must not masquerade as plugin removal.""" + client, _session = make_mock_session_client(make_client) + client._check_optional_get_endpoint = AsyncMock(return_value=("unavailable", {})) + try: + assert await client.get_speedtest() == { + "available": True, + "last": {}, + "average": {}, + } + finally: + await client.async_close() + + @pytest.mark.asyncio async def test_get_speedtest_normalizes_recent_and_stat_payloads(make_client) -> None: """get_speedtest should normalize showrecent and showstat payload fields.""" client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(side_effect=[True, True]) - client._safe_dict_get = AsyncMock( + client._check_optional_get_endpoint = AsyncMock( side_effect=[ - { - "date": "2026-03-14T03:09:45", - "server": "72800 RippleFiber, Newark, NJ", - "download": "836.05", - "upload": "832.97", - "latency": "4.0", - "url": "https://www.speedtest.net/result/c/abc", - }, - { - "samples": 10717, - "period": {"oldest": "2023-01-22 00:29:00", "youngest": "2026-03-14 03:09:45"}, - "latency": {"avg": 13.42, "min": 2.35, "max": 1266.74}, - "download": {"avg": 723.83, "min": 4.18, "max": 942.02}, - "upload": {"avg": 706.7, "min": 1.54, "max": 890.32}, - }, + ( + "available", + { + "date": "2026-03-14T03:09:45", + "server": "72800 RippleFiber, Newark, NJ", + "download": "836.05", + "upload": "832.97", + "latency": "4.0", + "url": "https://www.speedtest.net/result/c/abc", + }, + ), + ( + "available", + { + "samples": 10717, + "period": { + "oldest": "2023-01-22 00:29:00", + "youngest": "2026-03-14 03:09:45", + }, + "latency": {"avg": 13.42, "min": 2.35, "max": 1266.74}, + "download": {"avg": 723.83, "min": 4.18, "max": 942.02}, + "upload": {"avg": 706.7, "min": 1.54, "max": 890.32}, + }, + ), ] ) @@ -73,20 +96,21 @@ async def test_get_speedtest_normalizes_recent_and_stat_payloads(make_client) -> @pytest.mark.parametrize( - ("endpoint_side_effect", "safe_dict_get_payload", "showstat_available"), + ("optional_results", "showstat_available"), [ pytest.param( - [True, True], [ - {"download": "1", "upload": "2", "latency": "3"}, - {}, + ("available", {"download": "1", "upload": "2", "latency": "3"}), + ("available", {}), ], True, id="showstat-available", ), pytest.param( - [True, False], - {"download": "1", "upload": "2", "latency": "3"}, + [ + ("available", {"download": "1", "upload": "2", "latency": "3"}), + ("missing", {}), + ], False, id="showstat-missing", ), @@ -95,17 +119,14 @@ async def test_get_speedtest_normalizes_recent_and_stat_payloads(make_client) -> @pytest.mark.asyncio async def test_get_speedtest_probes_showstat_before_fetching_optional_payload( make_client: ClientType, - endpoint_side_effect: list[bool], - safe_dict_get_payload: list[dict[str, str]] | dict[str, str], + optional_results: list[tuple[str, dict[str, str]]], showstat_available: bool, ) -> None: """Validate ``get_speedtest`` probes ``showstat`` before optional fetches. Args: make_client (ClientType): Fixture factory returning ``OPNsenseClient`` instances. - endpoint_side_effect (list[bool]): Endpoint availability responses in call order. - safe_dict_get_payload (list[dict[str, str]] | dict[str, str]): Mocked payloads for - endpoint fetches. + optional_results: Optional endpoint states and payloads in call order. showstat_available (bool): Whether the ``showstat`` endpoint should be fetched. Returns: @@ -113,30 +134,21 @@ async def test_get_speedtest_probes_showstat_before_fetching_optional_payload( """ client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(side_effect=endpoint_side_effect) - if showstat_available: - client._safe_dict_get = AsyncMock(side_effect=safe_dict_get_payload) - else: - client._safe_dict_get = AsyncMock(return_value=safe_dict_get_payload) + client._check_optional_get_endpoint = AsyncMock(side_effect=optional_results) result = await client.get_speedtest() assert result["available"] is True - assert client._is_get_endpoint_available.await_args_list == [ + assert client._check_optional_get_endpoint.await_args_list == [ call("/api/speedtest/service/showrecent"), call("/api/speedtest/service/showstat"), ] if showstat_available: - assert client._safe_dict_get.await_args_list == [ - call("/api/speedtest/service/showrecent"), - call("/api/speedtest/service/showstat"), - ] assert result["last"]["download"]["value"] == 1.0 assert result["last"]["upload"]["value"] == 2.0 assert result["last"]["latency"]["value"] == 3.0 else: - client._safe_dict_get.assert_awaited_once_with("/api/speedtest/service/showrecent") assert result["last"]["download"]["value"] == 1.0 assert result["last"]["upload"]["value"] == 2.0 assert result["last"]["latency"]["value"] == 3.0 @@ -149,24 +161,29 @@ async def test_get_speedtest_normalizes_malformed_payloads(make_client) -> None: """get_speedtest should coerce malformed or missing values to None safely.""" client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(side_effect=[True, True]) - client._safe_dict_get = AsyncMock( + client._check_optional_get_endpoint = AsyncMock( side_effect=[ - { - "date": 12345, - "server": "Regional POP - NYC", - "download": "bad-number", - "upload": "12.5", - "latency": None, - "url": 999, - }, - { - "samples": "not-an-int", - "period": "bad-period-shape", - "download": "bad-download-shape", - "upload": None, - "latency": ["bad-latency-shape"], - }, + ( + "available", + { + "date": 12345, + "server": "Regional POP - NYC", + "download": "bad-number", + "upload": "12.5", + "latency": None, + "url": 999, + }, + ), + ( + "available", + { + "samples": "not-an-int", + "period": "bad-period-shape", + "download": "bad-download-shape", + "upload": None, + "latency": ["bad-latency-shape"], + }, + ), ] ) @@ -212,12 +229,15 @@ async def test_run_speedtest_uses_extended_timeout(make_client) -> None: """run_speedtest should use custom timeout helper for long-running endpoint calls.""" client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) + client._check_optional_get_endpoint = AsyncMock(return_value=("available", {})) client._safe_dict_get_with_timeout = AsyncMock(return_value={"timestamp": "x"}) result = await client.run_speedtest() assert result == {"timestamp": "x"} + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/speedtest/service/showrecent" + ) client._safe_dict_get_with_timeout.assert_awaited_once_with( "/api/speedtest/service/run", timeout_seconds=180 ) @@ -230,14 +250,14 @@ async def test_run_speedtest_returns_empty_when_endpoint_missing(make_client) -> """run_speedtest should return an empty payload when endpoint is unavailable.""" client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=False) + client._check_optional_get_endpoint = AsyncMock(return_value=("missing", {})) client._safe_dict_get_with_timeout = AsyncMock() result = await client.run_speedtest() assert result == {} client._safe_dict_get_with_timeout.assert_not_awaited() - client._is_get_endpoint_available.assert_awaited_once_with( + client._check_optional_get_endpoint.assert_awaited_once_with( "/api/speedtest/service/showrecent" ) finally: @@ -249,13 +269,13 @@ async def test_run_speedtest_returns_empty_for_non_mapping_response(make_client) """run_speedtest should return an empty payload for non-mapping responses.""" client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) + client._check_optional_get_endpoint = AsyncMock(return_value=("available", {})) client._safe_dict_get_with_timeout = AsyncMock(return_value=["not", "a", "mapping"]) result = await client.run_speedtest() assert result == {} - client._is_get_endpoint_available.assert_awaited_once_with( + client._check_optional_get_endpoint.assert_awaited_once_with( "/api/speedtest/service/showrecent" ) client._safe_dict_get_with_timeout.assert_awaited_once_with( diff --git a/tests/test_unbound.py b/tests/test_unbound.py index 36a0fc5..99601ea 100644 --- a/tests/test_unbound.py +++ b/tests/test_unbound.py @@ -49,16 +49,18 @@ async def test_get_unbound_blocklist_returns_uuid_mapping(make_client) -> None: client, _session = make_mock_session_client(make_client) try: client.get_host_firmware_version = AsyncMock(return_value="25.7.8") - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock( - return_value={ - "rows": [ - {"uuid": "dnsbl1", "enabled": "1"}, - {"uuid": "dnsbl2", "enabled": "0"}, - {"no_uuid": "skip-me"}, - "bad-row", - ] - } + client._check_optional_get_endpoint = AsyncMock( + return_value=( + "available", + { + "rows": [ + {"uuid": "dnsbl1", "enabled": "1"}, + {"uuid": "dnsbl2", "enabled": "0"}, + {"no_uuid": "skip-me"}, + "bad-row", + ] + }, + ) ) result = await client.get_unbound_blocklist() @@ -67,10 +69,9 @@ async def test_get_unbound_blocklist_returns_uuid_mapping(make_client) -> None: "dnsbl1": {"uuid": "dnsbl1", "enabled": "1"}, "dnsbl2": {"uuid": "dnsbl2", "enabled": "0"}, } - client._is_get_endpoint_available.assert_awaited_once_with( + client._check_optional_get_endpoint.assert_awaited_once_with( "/api/unbound/settings/search_dnsbl" ) - client._safe_dict_get.assert_awaited_once_with("/api/unbound/settings/search_dnsbl") finally: await client.async_close() @@ -84,41 +85,45 @@ async def test_get_unbound_blocklist_handles_empty_or_invalid_responses( client = make_client() try: client.get_host_firmware_version = AsyncMock(return_value="25.7.8") - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock(return_value=api_response) + client._check_optional_get_endpoint = AsyncMock(return_value=("available", api_response)) result = await client.get_unbound_blocklist() assert result == {} + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/unbound/settings/search_dnsbl" + ) finally: await client.async_close() @pytest.mark.asyncio -async def test_get_unbound_blocklist_returns_empty_when_endpoint_unavailable( +@pytest.mark.parametrize("status", ["missing", "unavailable", "malformed"]) +async def test_get_unbound_blocklist_returns_empty_for_non_available_optional_status( make_client: ClientType, + status: str, ) -> None: - """When DNSBL endpoint is unavailable, blocklist retrieval should fail closed. + """DNSBL endpoint status other than ``available`` should normalize to ``{}``. Args: make_client (ClientType): Fixture factory returning ``OPNsenseClient`` instances. + status (str): Mocked endpoint status from + ``_check_optional_get_endpoint``. Returns: - None: This test validates fail-closed DNSBL lookup behavior. + None: This test validates non-``available`` DNSBL status handling. """ client, _session = make_mock_session_client(make_client) try: client.get_host_firmware_version = AsyncMock(return_value="25.7.8") - client._is_get_endpoint_available = AsyncMock(return_value=False) - client._safe_dict_get = AsyncMock() + client._check_optional_get_endpoint = AsyncMock(return_value=(status, {})) result = await client.get_unbound_blocklist() assert result == {} - client._is_get_endpoint_available.assert_awaited_once_with( + client._check_optional_get_endpoint.assert_awaited_once_with( "/api/unbound/settings/search_dnsbl" ) - client._safe_dict_get.assert_not_awaited() finally: await client.async_close() diff --git a/tests/test_vnstat.py b/tests/test_vnstat.py index 344eb51..eeacc33 100644 --- a/tests/test_vnstat.py +++ b/tests/test_vnstat.py @@ -52,10 +52,8 @@ async def test_get_vnstat_metrics_yearly_parsing(make_client) -> None: """get_vnstat_metrics should parse yearly vnStat payload rows.""" client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock( - return_value={ - "response": """ + yearly_payload = { + "response": """ igc0 / yearly year rx | tx | total | avg. rate @@ -65,10 +63,10 @@ async def test_get_vnstat_metrics_yearly_parsing(make_client) -> None: 2025 35.72 TiB | 27.26 TiB | 62.99 TiB | 17.57 Mbit/s 2026 7.74 TiB | 2.36 TiB | 10.09 TiB | 14.15 Mbit/s ------------------------+-------------+-------------+--------------- - estimated 38.88 TiB | 11.85 TiB | 50.73 TiB | + estimated 38.88 TiB | 11.85 TiB | 50.73 TiB | """ - } - ) + } + client._check_optional_get_endpoint = AsyncMock(return_value=("available", yearly_payload)) parsed = await client.get_vnstat_metrics("yearly") rows = parsed["interfaces"]["igc0"] @@ -80,7 +78,7 @@ async def test_get_vnstat_metrics_yearly_parsing(make_client) -> None: assert rows[0]["total_bytes"] == int(round(63.25 * tib)) assert rows[3]["label"] == "2026" assert rows[3]["avg_rate_bits_per_second"] == 14150000 - client._safe_dict_get.assert_awaited_once_with("/api/vnstat/service/yearly") + client._check_optional_get_endpoint.assert_awaited_once_with("/api/vnstat/service/yearly") finally: await client.async_close() @@ -90,16 +88,16 @@ async def test_get_vnstat_metrics_unsupported_period_or_endpoint_missing(make_cl """get_vnstat_metrics should return empty data for unsupported/missing endpoints.""" client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=False) + client._check_optional_get_endpoint = AsyncMock(return_value=("unavailable", {})) client._safe_dict_get = AsyncMock() assert await client.get_vnstat_metrics("hourly") == {} client._safe_dict_get.assert_not_awaited() - client._is_get_endpoint_available.assert_awaited_once_with("/api/vnstat/service/hourly") + client._check_optional_get_endpoint.assert_awaited_once_with("/api/vnstat/service/hourly") - client._is_get_endpoint_available.reset_mock() + client._check_optional_get_endpoint.reset_mock() assert await client.get_vnstat_metrics("weekly") == {} - client._is_get_endpoint_available.assert_not_awaited() + client._check_optional_get_endpoint.assert_not_awaited() finally: await client.async_close() @@ -109,7 +107,6 @@ async def test_get_vnstat_summary_from_hourly_daily_monthly(make_client) -> None """get_vnstat should produce per-interface summary fields used by sensors.""" client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) # Keep payload dates aligned with mocked OPNsense system time to avoid # day-boundary/timezone flakiness in CI. now = datetime(2000, 1, 15, 12, 0, 0, tzinfo=UTC) @@ -165,29 +162,28 @@ async def test_get_vnstat_summary_from_hourly_daily_monthly(make_client) -> None """ } - async def fake_safe_get(path: str, *_args: Any, **_kwargs: Any) -> dict[str, Any]: - """Return mocked vnStat/system-time payloads by endpoint path. - - Args: - path (str): API endpoint path to request. - *_args (Any): args used by this operation. - **_kwargs (Any): kwargs used by this operation. - - Returns: - dict[str, Any]: Mocked vnStat/system-time payloads by endpoint path. - """ + async def fake_check_optional_get( + path: str, *_args: Any, **_kwargs: Any + ) -> tuple[str, dict[str, Any]]: + """Return mocked vnStat payloads by endpoint path.""" if path == "/api/vnstat/service/hourly": - return hourly_payload + return "available", hourly_payload if path == "/api/vnstat/service/daily": - return daily_payload + return "available", daily_payload if path == "/api/vnstat/service/monthly": - return monthly_payload + return "available", monthly_payload + return "unavailable", {} + + async def fake_safe_get(path: str, *_args: Any, **_kwargs: Any) -> dict[str, Any]: + """Return mocked system-time payloads by endpoint path.""" if path == "/api/diagnostics/system/system_time": return {"datetime": "2000-01-15 12:00:00 EST"} return {} + client._check_optional_get_endpoint = AsyncMock(side_effect=fake_check_optional_get) client._safe_dict_get = AsyncMock(side_effect=fake_safe_get) vnstat = await client.get_vnstat() + client._safe_dict_get.assert_awaited_once_with("/api/diagnostics/system/system_time") gib = 1024**3 assert vnstat["interface_count"] == 2 @@ -217,7 +213,9 @@ async def test_get_vnstat_uses_systemtime_endpoint_path(make_client) -> None: """get_vnstat should query the supported system-time endpoint.""" client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) + client._check_optional_get_endpoint = AsyncMock( + return_value=("available", {"response": ""}) + ) async def fake_safe_get(path: str, *_args: Any, **_kwargs: Any) -> dict[str, Any]: """Return mocked payloads for system-time endpoint coverage. @@ -238,6 +236,7 @@ async def fake_safe_get(path: str, *_args: Any, **_kwargs: Any) -> dict[str, Any await client.get_vnstat() client._safe_dict_get.assert_any_await("/api/diagnostics/system/system_time") + client._check_optional_get_endpoint.assert_any_await("/api/vnstat/service/hourly") finally: await client.async_close() @@ -247,13 +246,40 @@ async def test_get_vnstat_skips_calls_when_endpoint_missing(make_client) -> None """get_vnstat should return empty payload and skip API calls when endpoint is absent.""" client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=False) + client._check_optional_get_endpoint = AsyncMock(return_value=("missing", {})) client._safe_dict_get = AsyncMock() vnstat = await client.get_vnstat() assert vnstat == {"interfaces": {}, "interface_count": 0} client._safe_dict_get.assert_not_awaited() - client._is_get_endpoint_available.assert_awaited_once_with("/api/vnstat/service/hourly") + client._check_optional_get_endpoint.assert_awaited_once_with("/api/vnstat/service/hourly") + finally: + await client.async_close() + + +@pytest.mark.parametrize( + "state,payload", + [ + ("missing", {}), + ("unavailable", {}), + ("malformed", {"response": ""}), + ], +) +@pytest.mark.asyncio +async def test_get_vnstat_fallback_for_optional_states( + state: str, + payload: dict[str, Any], + make_client: Any, +) -> None: + """get_vnstat should return defaults when hourly endpoint state is unavailable.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock(return_value=(state, payload)) + client._safe_dict_get = AsyncMock() + + assert await client.get_vnstat() == {"interfaces": {}, "interface_count": 0} + client._safe_dict_get.assert_not_awaited() + client._check_optional_get_endpoint.assert_awaited_once_with("/api/vnstat/service/hourly") finally: await client.async_close() From 4a028b7cf04c2c5e290595893af674c61b908bdb Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 14:07:10 -0400 Subject: [PATCH 02/14] Reconcile ISC DHCP plugin endpoints --- aiopnsense/client_endpoint.py | 4 + aiopnsense/dhcp.py | 16 +-- tests/test_client_endpoint.py | 29 ++++++ tests/test_dhcp.py | 180 ++++++++++++++++++++-------------- 4 files changed, 145 insertions(+), 84 deletions(-) diff --git a/aiopnsense/client_endpoint.py b/aiopnsense/client_endpoint.py index 3a1d5fd..4f96284 100644 --- a/aiopnsense/client_endpoint.py +++ b/aiopnsense/client_endpoint.py @@ -57,6 +57,10 @@ async def _safe_dict_get(self, path: str) -> dict[str, Any]: ... "/api/speedtest/service/showlog", "/api/speedtest/service/showstat", "/api/nut/diagnostics/upsstatus", + "/api/dhcpv4/leases/search_lease", + "/api/dhcpv4/leases/searchLease", + "/api/dhcpv6/leases/search_lease", + "/api/dhcpv6/leases/searchLease", "/api/unbound/settings/search_dnsbl", "/api/vnstat/service/hourly", "/api/vnstat/service/daily", diff --git a/aiopnsense/dhcp.py b/aiopnsense/dhcp.py index 0b57819..bd920f8 100644 --- a/aiopnsense/dhcp.py +++ b/aiopnsense/dhcp.py @@ -22,10 +22,8 @@ KEA_DHCPV4_SEARCH_RESERVATION_ENDPOINT = "/api/kea/dhcpv4/search_reservation" KEA_DHCPV4_SEARCH_RESERVATION_CAMELCASE_ENDPOINT = "/api/kea/dhcpv4/searchReservation" DNSMASQ_LEASES_SEARCH_ENDPOINT = "/api/dnsmasq/leases/search" -ISC_DHCPV4_SERVICE_STATUS_ENDPOINT = "/api/dhcpv4/service/status" ISC_DHCPV4_LEASES_SEARCH_ENDPOINT = "/api/dhcpv4/leases/search_lease" ISC_DHCPV4_LEASES_SEARCH_CAMELCASE_ENDPOINT = "/api/dhcpv4/leases/searchLease" -ISC_DHCPV6_SERVICE_STATUS_ENDPOINT = "/api/dhcpv6/service/status" ISC_DHCPV6_LEASES_SEARCH_ENDPOINT = "/api/dhcpv6/leases/search_lease" ISC_DHCPV6_LEASES_SEARCH_CAMELCASE_ENDPOINT = "/api/dhcpv6/leases/searchLease" @@ -453,17 +451,14 @@ async def _get_isc_dhcpv4_leases(self, opnsense_tz: tzinfo | None = None) -> lis list: Normalized ISC DHCPv4 lease entries. Non-active, expired, malformed, and MAC-less rows are omitted. """ - if not await self._is_get_endpoint_available(ISC_DHCPV4_SERVICE_STATUS_ENDPOINT): - _LOGGER.debug("ISC DHCP not installed") - return [] lease_endpoint = await self._get_endpoint_path( snake_case_path=ISC_DHCPV4_LEASES_SEARCH_ENDPOINT, camel_case_path=ISC_DHCPV4_LEASES_SEARCH_CAMELCASE_ENDPOINT, ) - if not await self._is_get_endpoint_available(lease_endpoint): + lease_status, response = await self._check_optional_get_endpoint(lease_endpoint) + if lease_status != "available" or not isinstance(response, MutableMapping): _LOGGER.debug("ISC DHCPv4 lease endpoint unavailable") return [] - response = await self._safe_dict_get(lease_endpoint) leases_info: list = response.get("rows", []) if not isinstance(leases_info, list): return [] @@ -515,17 +510,14 @@ async def _get_isc_dhcpv6_leases(self, opnsense_tz: tzinfo | None = None) -> lis list: Normalized ISC DHCPv6 lease entries. Non-active, expired, malformed, and MAC-less rows are omitted. """ - if not await self._is_get_endpoint_available(ISC_DHCPV6_SERVICE_STATUS_ENDPOINT): - _LOGGER.debug("ISC DHCP not installed") - return [] lease_endpoint = await self._get_endpoint_path( snake_case_path=ISC_DHCPV6_LEASES_SEARCH_ENDPOINT, camel_case_path=ISC_DHCPV6_LEASES_SEARCH_CAMELCASE_ENDPOINT, ) - if not await self._is_get_endpoint_available(lease_endpoint): + lease_status, response = await self._check_optional_get_endpoint(lease_endpoint) + if lease_status != "available" or not isinstance(response, MutableMapping): _LOGGER.debug("ISC DHCPv6 lease endpoint unavailable") return [] - response = await self._safe_dict_get(lease_endpoint) leases_info: list = response.get("rows", []) if not isinstance(leases_info, list): return [] diff --git a/tests/test_client_endpoint.py b/tests/test_client_endpoint.py index af3b733..2492ddc 100644 --- a/tests/test_client_endpoint.py +++ b/tests/test_client_endpoint.py @@ -957,6 +957,35 @@ async def test_check_optional_get_endpoint_cached_positive_not_mutated_by_transi await client.async_close() +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path", + [ + "/api/dhcpv4/leases/search_lease", + "/api/dhcpv4/leases/searchLease", + "/api/dhcpv6/leases/search_lease", + "/api/dhcpv6/leases/searchLease", + ], +) +async def test_isc_dhcp_lease_paths_are_exact_optional_capabilities( + path: str, + make_client: MakeClientFactory, +) -> None: + """Each supported ISC DHCP lease spelling has its own optional cache key.""" + client, _session = make_mock_session_client(make_client) + client._get_optional = AsyncMock(return_value=("available", {"rows": []})) + + try: + assert await client._check_optional_get_endpoint(path) == ( + "available", + {"rows": []}, + ) + assert client._endpoint_availability[("get", path)] is True + client._get_optional.assert_awaited_once_with(path) + finally: + await client.async_close() + + @pytest.mark.asyncio @pytest.mark.parametrize("state", ["available", "malformed"]) async def test_check_optional_post_endpoint_refreshes_positive_exact_cache_key( diff --git a/tests/test_dhcp.py b/tests/test_dhcp.py index 63ae472..70cbaae 100644 --- a/tests/test_dhcp.py +++ b/tests/test_dhcp.py @@ -186,8 +186,6 @@ async def test_get_arp_table_uses_get_query_param(make_client: ClientType) -> No ("_get_kea_dhcpv4_leases", "/api/kea/leases4/search"), ("_get_kea_dhcpv6_leases", "/api/kea/leases6/search"), ("_get_dnsmasq_leases", "/api/dnsmasq/leases/search"), - ("_get_isc_dhcpv4_leases", "/api/dhcpv4/service/status"), - ("_get_isc_dhcpv6_leases", "/api/dhcpv6/service/status"), ], ) async def test_dhcp_endpoint_unavailable( @@ -217,6 +215,39 @@ async def test_dhcp_endpoint_unavailable( await client.async_close() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "endpoint"), + [ + ("_get_isc_dhcpv4_leases", "/api/dhcpv4/leases/search_lease"), + ("_get_isc_dhcpv6_leases", "/api/dhcpv6/leases/search_lease"), + ], +) +@pytest.mark.parametrize("status", ["missing", "unavailable", "malformed"]) +async def test_isc_dhcp_optional_endpoint_failure( + make_client: ClientType, + method_name: str, + endpoint: str, + status: str, +) -> None: + """ISC DHCP lease helpers fail closed for non-available plugin states. + + Args: + make_client: Fixture factory returning ``OPNsenseClient`` instances. + method_name: ISC lease helper method name to invoke. + endpoint: Selected optional lease endpoint. + status: Optional endpoint state returned by the reconciliation helper. + """ + client, _session = make_mock_session_client(make_client) + client._use_snake_case = True + client._check_optional_get_endpoint = AsyncMock(return_value=(status, {})) + try: + assert await getattr(client, method_name)() == [] + client._check_optional_get_endpoint.assert_awaited_once_with(endpoint) + finally: + await client.async_close() + + @pytest.mark.asyncio @pytest.mark.parametrize( ("reservations", "expected_key", "expected_expire"), @@ -386,9 +417,9 @@ async def test_get_isc_dhcpv4_and_v6_parsing(make_client: ClientType) -> None: # v4: ends present and in future future_dt = (datetime.now(tz=local_tz) + timedelta(hours=1)).strftime("%Y/%m/%d %H:%M:%S") - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock( - side_effect=[ + client._check_optional_get_endpoint = AsyncMock( + return_value=( + "available", { "rows": [ { @@ -401,34 +432,35 @@ async def test_get_isc_dhcpv4_and_v6_parsing(make_client: ClientType) -> None: } ] }, - {"rows": []}, - ] + ) ) - v4_safe_dict_get = client._safe_dict_get v4 = await client._get_isc_dhcpv4_leases() assert isinstance(v4, list) and len(v4) == 1 assert v4[0]["address"] == "10.0.0.1" assert v4[0]["mac"] == "m1" assert v4[0]["hostname"] == "h1" assert isinstance(v4[0].get("expires"), datetime) - v4_safe_dict_get.assert_awaited_once_with("/api/dhcpv4/leases/search_lease") + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/dhcpv4/leases/search_lease" + ) # v6: ends missing -> field passed through - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock( - return_value={ - "rows": [ - { - "state": "active", - "mac": "m2", - "address": "fe80::1", - "hostname": "h2", - "if": "em1", - } - ] - } + client._check_optional_get_endpoint = AsyncMock( + return_value=( + "available", + { + "rows": [ + { + "state": "active", + "mac": "m2", + "address": "fe80::1", + "hostname": "h2", + "if": "em1", + } + ] + }, + ) ) - v6_safe_dict_get = client._safe_dict_get v6 = await client._get_isc_dhcpv6_leases() assert isinstance(v6, list) and len(v6) == 1 assert v6[0]["address"] == "fe80::1" @@ -438,7 +470,9 @@ async def test_get_isc_dhcpv4_and_v6_parsing(make_client: ClientType) -> None: assert v6[0].get("expires") is None assert "ends_at" not in v6[0] or v6[0]["ends_at"] is None assert "expiry" not in v6[0] or v6[0]["expiry"] is None - v6_safe_dict_get.assert_awaited_once_with("/api/dhcpv6/leases/search_lease") + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/dhcpv6/leases/search_lease" + ) finally: await client.async_close() @@ -824,21 +858,27 @@ async def test_get_isc_dhcpv4_and_v6_cover_invalid_and_expired_paths( local_tz = datetime.now().astimezone().tzinfo assert local_tz is not None client._get_opnsense_timezone = AsyncMock(return_value=local_tz) - client._is_get_endpoint_available = AsyncMock(return_value=True) - past_str = (datetime.now(tz=local_tz) - timedelta(hours=2)).strftime("%Y/%m/%d %H:%M:%S") - client._safe_dict_get = AsyncMock( + client._check_optional_get_endpoint = AsyncMock( side_effect=[ - {"rows": "bad"}, - { - "rows": [ - {"state": "inactive", "mac": "skip"}, - {"state": "active", "mac": "bad-time", "ends": "invalid-date"}, - {"state": "active", "mac": "expired", "ends": past_str}, - {"state": "active", "mac": "ok", "address": "10.0.0.9", "if": "em0"}, - ] - }, + ("available", {"rows": "bad"}), + ( + "available", + { + "rows": [ + {"state": "inactive", "mac": "skip"}, + {"state": "active", "mac": "bad-time", "ends": "invalid-date"}, + {"state": "active", "mac": "expired", "ends": past_str}, + { + "state": "active", + "mac": "ok", + "address": "10.0.0.9", + "if": "em0", + }, + ] + }, + ), ] ) assert await client._get_isc_dhcpv4_leases() == [] @@ -847,17 +887,29 @@ async def test_get_isc_dhcpv4_and_v6_cover_invalid_and_expired_paths( assert v4_leases[0]["mac"] == "ok" assert v4_leases[0]["expires"] is None - client._safe_dict_get = AsyncMock( + client._check_optional_get_endpoint = AsyncMock( side_effect=[ - {"rows": "bad"}, - { - "rows": [ - None, - {"state": "active", "mac": "bad-time-v6", "ends": "invalid-date"}, - {"state": "active", "mac": "expired-v6", "ends": past_str}, - {"state": "active", "mac": "ok-v6", "address": "2001:db8::10", "if": "em1"}, - ] - }, + ("available", {"rows": "bad"}), + ( + "available", + { + "rows": [ + None, + { + "state": "active", + "mac": "bad-time-v6", + "ends": "invalid-date", + }, + {"state": "active", "mac": "expired-v6", "ends": past_str}, + { + "state": "active", + "mac": "ok-v6", + "address": "2001:db8::10", + "if": "em1", + }, + ] + }, + ), ] ) assert await client._get_isc_dhcpv6_leases() == [] @@ -899,33 +951,17 @@ async def test_version_switched_dhcp_endpoints_rows_empty_when_reservation_unava == "/api/kea/dhcpv4/search_reservation" ) - client._is_get_endpoint_available = AsyncMock(side_effect=[True, False]) - client._safe_dict_get = AsyncMock() + client._check_optional_get_endpoint = AsyncMock(return_value=("missing", {})) assert await client._get_isc_dhcpv4_leases() == [] - assert client._is_get_endpoint_available.await_count == 2 - assert ( - client._is_get_endpoint_available.await_args_list[0].args[0] - == "/api/dhcpv4/service/status" + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/dhcpv4/leases/search_lease" ) - assert ( - client._is_get_endpoint_available.await_args_list[1].args[0] - == "/api/dhcpv4/leases/search_lease" - ) - client._safe_dict_get.assert_not_awaited() - client._is_get_endpoint_available = AsyncMock(side_effect=[True, False]) - client._safe_dict_get = AsyncMock() + client._check_optional_get_endpoint = AsyncMock(return_value=("missing", {})) assert await client._get_isc_dhcpv6_leases() == [] - assert client._is_get_endpoint_available.await_count == 2 - assert ( - client._is_get_endpoint_available.await_args_list[0].args[0] - == "/api/dhcpv6/service/status" - ) - assert ( - client._is_get_endpoint_available.await_args_list[1].args[0] - == "/api/dhcpv6/leases/search_lease" + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/dhcpv6/leases/search_lease" ) - client._safe_dict_get.assert_not_awaited() finally: await client.async_close() @@ -1027,13 +1063,13 @@ async def test_dhcp_switched_endpoints_follow_selected_case( await client._get_kea_dhcpv4_leases() assert client._safe_dict_get.await_args_list[1].args[0] == expected_kea - client._safe_dict_get = AsyncMock(return_value={"rows": []}) + client._check_optional_get_endpoint = AsyncMock(return_value=("available", {"rows": []})) await client._get_isc_dhcpv4_leases() - client._safe_dict_get.assert_awaited_once_with(expected_v4) + client._check_optional_get_endpoint.assert_awaited_once_with(expected_v4) - client._safe_dict_get = AsyncMock(return_value={"rows": []}) + client._check_optional_get_endpoint = AsyncMock(return_value=("available", {"rows": []})) await client._get_isc_dhcpv6_leases() - client._safe_dict_get.assert_awaited_once_with(expected_v6) + client._check_optional_get_endpoint.assert_awaited_once_with(expected_v6) finally: await client.async_close() From 8dd2b25ba4da1621baa04d0dde367402238b2904 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 14:14:39 -0400 Subject: [PATCH 03/14] Address optional endpoint review feedback --- aiopnsense/client_endpoint.py | 24 +++++++++++++++++++++++- aiopnsense/speedtest.py | 9 +++++++-- tests/test_speedtest.py | 33 ++++++++++++++++++++++++++++++--- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/aiopnsense/client_endpoint.py b/aiopnsense/client_endpoint.py index 4f96284..eff3d70 100644 --- a/aiopnsense/client_endpoint.py +++ b/aiopnsense/client_endpoint.py @@ -473,7 +473,29 @@ async def _check_optional_endpoint( payload: MutableMapping[str, Any] | None, force_refresh: bool, ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: - """Run one real optional request and reconcile its registered cache key.""" + """Run a live optional probe and reconcile the optional endpoint cache key. + + Args: + method (Literal["get", "post"]): HTTP method for the optional probe. + path (str): API endpoint path that is requested. + cache_path (str): Cache key to reconcile against, typically + ``path`` or a normalized cache alias. + payload (MutableMapping[str, Any] | None): Optional request payload + used only for POST probes. + force_refresh (bool): Whether to bypass stale/confirmed cache state. + + Returns: + tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + ``("available", payload)`` or ``("malformed", payload)`` when the + request is reachable and can be interpreted as a response; ``("missing", {})`` only after a + confirmed 404 path; ``("unavailable", {})`` for transient failures + or unregistered optional endpoints. + + Notes: + The method only short-circuits via a fresh confirmed-negative cache + state. A positive cache entry does not suppress a real request because + callers consume its payload and still need a fresh probe result. + """ if not path or not self._is_optional_endpoint(method, cache_path): _LOGGER.debug("Unregistered optional endpoint: %s %s", method.upper(), path) return "unavailable", {} diff --git a/aiopnsense/speedtest.py b/aiopnsense/speedtest.py index d30eb44..c7019ee 100644 --- a/aiopnsense/speedtest.py +++ b/aiopnsense/speedtest.py @@ -155,14 +155,19 @@ async def run_speedtest(self) -> dict[str, Any]: Returns: dict[str, Any]: Response mapping from the Speedtest ``run`` endpoint, or an empty mapping when the plugin endpoint is - unavailable or returns a malformed payload. + unavailable or cannot be safely invoked. """ optional_state, _payload = await self._check_optional_get_endpoint( SPEEDTEST_SHOW_LOG_ENDPOINT ) - if optional_state != "available": + if optional_state == "missing": _LOGGER.debug("Speedtest not installed") return {} + if optional_state == "unavailable": + _LOGGER.debug("Speedtest temporarily unavailable") + return {} + if optional_state == "malformed": + _LOGGER.debug("Speedtest probe returned malformed payload; proceeding with run request") response = await self._safe_dict_get_with_timeout( SPEEDTEST_RUN_ENDPOINT, diff --git a/tests/test_speedtest.py b/tests/test_speedtest.py index d6d5ebb..566bc2e 100644 --- a/tests/test_speedtest.py +++ b/tests/test_speedtest.py @@ -355,12 +355,15 @@ async def test_run_speedtest_uses_extended_timeout(make_client) -> None: await client.async_close() +@pytest.mark.parametrize("optional_state", ["missing", "unavailable"]) @pytest.mark.asyncio -async def test_run_speedtest_returns_empty_when_endpoint_missing(make_client) -> None: - """run_speedtest should return an empty payload when endpoint is unavailable.""" +async def test_run_speedtest_returns_empty_when_endpoint_not_ready( + make_client, optional_state: str +) -> None: + """run_speedtest should return an empty payload when probe is absent or blocked.""" client, _session = make_mock_session_client(make_client) try: - client._check_optional_get_endpoint = AsyncMock(return_value=("missing", {})) + client._check_optional_get_endpoint = AsyncMock(return_value=(optional_state, {})) client._safe_dict_get_with_timeout = AsyncMock() result = await client.run_speedtest() @@ -374,6 +377,30 @@ async def test_run_speedtest_returns_empty_when_endpoint_missing(make_client) -> await client.async_close() +@pytest.mark.parametrize("optional_state", ["available", "malformed"]) +@pytest.mark.asyncio +async def test_run_speedtest_allows_malformed_and_available_probe_payloads( + make_client: ClientType, optional_state: str +) -> None: + """run_speedtest should proceed when probe payload is malformed or available.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock(return_value=(optional_state, {})) + client._safe_dict_get_with_timeout = AsyncMock(return_value={"timestamp": "x"}) + + result = await client.run_speedtest() + + assert result == {"timestamp": "x"} + client._check_optional_get_endpoint.assert_awaited_once_with( + "/api/speedtest/service/showlog" + ) + client._safe_dict_get_with_timeout.assert_awaited_once_with( + "/api/speedtest/service/run", timeout_seconds=180 + ) + finally: + await client.async_close() + + @pytest.mark.asyncio async def test_run_speedtest_returns_empty_for_non_mapping_response(make_client) -> None: """run_speedtest should return an empty payload for non-mapping responses.""" From 91aa12ff919b8c68c42c6cd177c1c95dce10c958 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 18:18:00 -0400 Subject: [PATCH 04/14] Add authoritative optional category results --- aiopnsense/__init__.py | 3 + aiopnsense/_typing.py | 71 +++++++++++++++++--- aiopnsense/client_base.py | 5 +- aiopnsense/client_endpoint.py | 114 ++++++++++++++++++--------------- aiopnsense/client_queue.py | 24 ++----- aiopnsense/client_transport.py | 27 ++++---- aiopnsense/dhcp.py | 73 ++++++++++++++++++++- aiopnsense/smart.py | 25 +++++--- aiopnsense/speedtest.py | 9 +-- aiopnsense/unbound.py | 30 ++++++--- aiopnsense/vnstat.py | 27 +++++--- tests/test_category_result.py | 39 +++++++++++ tests/test_client_endpoint.py | 90 +++++++++++++------------- 13 files changed, 368 insertions(+), 169 deletions(-) create mode 100644 tests/test_category_result.py diff --git a/aiopnsense/__init__.py b/aiopnsense/__init__.py index 3fae22a..ea54d8a 100644 --- a/aiopnsense/__init__.py +++ b/aiopnsense/__init__.py @@ -1,6 +1,7 @@ """aiopnsense package to manage OPNsense.""" from .client import OPNsenseClient +from ._typing import CategoryResult, CategoryState from .exceptions import ( OPNsenseBelowMinFirmware, OPNsenseConnectionError, @@ -17,6 +18,8 @@ ) __all__ = [ + "CategoryResult", + "CategoryState", "OPNsenseBelowMinFirmware", "OPNsenseClient", "OPNsenseConnectionError", diff --git a/aiopnsense/_typing.py b/aiopnsense/_typing.py index 78fdd0b..985901a 100644 --- a/aiopnsense/_typing.py +++ b/aiopnsense/_typing.py @@ -2,30 +2,75 @@ import asyncio from collections.abc import AsyncGenerator, MutableMapping +from dataclasses import dataclass from datetime import tzinfo -from typing import Any, Literal, Protocol +from typing import Any, Iterator, Literal, Protocol + + +type CategoryState = Literal["available", "pending", "missing", "transient", "malformed"] +EndpointAvailabilityState = Literal["available", "missing", "pending"] + + +@dataclass(frozen=True, slots=True) +class CategoryResult[T]: + """Immutable data and availability result for an optional API category.""" + + data: T + state: CategoryState + authoritative: bool + + @staticmethod + def coerce(value: object) -> "CategoryResult[object]": + """Normalize legacy internal tuple results during the contract migration.""" + if isinstance(value, CategoryResult): + return value + if isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], str): + state: CategoryState | str = "transient" if value[0] == "unavailable" else value[0] + if state in {"available", "pending", "missing", "transient", "malformed"}: + typed_state: CategoryState = state + return CategoryResult( + value[1], typed_state, typed_state in {"available", "missing", "malformed"} + ) + return CategoryResult({}, "malformed", True) + + def __iter__(self) -> Iterator[object]: + """Yield legacy state/data tuple values for internal compatibility.""" + yield self.state + yield self.data + + def __eq__(self, other: object) -> bool: + """Compare result objects, with temporary support for legacy tuples.""" + if isinstance(other, CategoryResult): + return ( + self.data == other.data + and self.state == other.state + and self.authoritative == other.authoritative + ) + if isinstance(other, tuple) and len(other) == 2: + legacy_state = "transient" if other[0] == "unavailable" else other[0] + return self.state == legacy_state and self.data == other[1] + return NotImplemented class AiopnsenseClientProtocol(Protocol): """Structural typing contract used by split aiopnsense mixins.""" _throw_errors: bool - _endpoint_availability: dict[tuple[Literal["get", "post"], str], bool] + _use_snake_case: bool | None + _endpoint_availability: dict[tuple[Literal["get", "post"], str], EndpointAvailabilityState] _endpoint_checked_at: dict[tuple[Literal["get", "post"], str], float] _endpoint_locks: dict[tuple[Literal["get", "post"], str], asyncio.Lock] _optional_endpoint_missing_pending_confirmation: set[tuple[Literal["get", "post"], str]] async def _get(self, path: str) -> MutableMapping[str, Any] | list | None: ... - async def _get_optional( - self, path: str - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: ... + async def _get_optional(self, path: str) -> CategoryResult[object]: ... async def _post_optional( self, path: str, payload: MutableMapping[str, Any] | None = None, - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: ... + ) -> CategoryResult[object]: ... async def _get_text(self, path: str) -> str | None: ... @@ -78,7 +123,7 @@ async def _check_optional_get_endpoint( path: str, *, force_refresh: bool = False, - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: ... + ) -> CategoryResult[object]: ... async def _check_optional_post_endpoint( self, @@ -87,4 +132,14 @@ async def _check_optional_post_endpoint( cache_path: str | None = None, *, force_refresh: bool = False, - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: ... + ) -> CategoryResult[object]: ... + + async def get_smart_result(self) -> CategoryResult[list[dict[str, Any]]]: ... + + async def get_vnstat_result(self) -> CategoryResult[MutableMapping[str, Any]]: ... + + async def get_unbound_blocklist_result(self) -> CategoryResult[dict[str, Any]]: ... + + async def get_dhcp_leases_result( + self, opnsense_tz: tzinfo | None = None + ) -> CategoryResult[dict[str, Any]]: ... diff --git a/aiopnsense/client_base.py b/aiopnsense/client_base.py index 87f10b6..8250a6e 100644 --- a/aiopnsense/client_base.py +++ b/aiopnsense/client_base.py @@ -13,6 +13,7 @@ from .client_transport import ClientTransportMixin from .const import DEFAULT_CACHE_TTL_SECONDS, DEFAULT_NEGATIVE_CACHE_TTL_SECONDS from .exceptions import OPNsenseInvalidArgument +from ._typing import EndpointAvailabilityState _UNSET: object = object() @@ -79,7 +80,9 @@ def __init__( self._throw_errors = initial self._firmware_version: str | None = None self._use_snake_case: bool | None = None - self._endpoint_availability: dict[tuple[Literal["get", "post"], str], bool] = {} + self._endpoint_availability: dict[ + tuple[Literal["get", "post"], str], EndpointAvailabilityState + ] = {} self._endpoint_checked_at: dict[tuple[Literal["get", "post"], str], float] = {} self._endpoint_locks: dict[tuple[Literal["get", "post"], str], asyncio.Lock] = {} self._optional_endpoint_missing_pending_confirmation: set[ diff --git a/aiopnsense/client_endpoint.py b/aiopnsense/client_endpoint.py index eff3d70..e81989e 100644 --- a/aiopnsense/client_endpoint.py +++ b/aiopnsense/client_endpoint.py @@ -8,7 +8,11 @@ import aiohttp -from ._typing import AiopnsenseClientProtocol +from ._typing import ( + AiopnsenseClientProtocol, + CategoryResult, + EndpointAvailabilityState, +) from .const import ( DEFAULT_REQUEST_TIMEOUT_SECONDS, LEGACY_CAMELCASE_ENDPOINT_FIRMWARE, @@ -21,7 +25,7 @@ class ClientEndpointMixin: """Endpoint selection and availability methods for OPNsenseClient.""" if TYPE_CHECKING: - _endpoint_availability: dict[tuple[Literal["get", "post"], str], bool] + _endpoint_availability: dict[tuple[Literal["get", "post"], str], EndpointAvailabilityState] _endpoint_cache_ttl_seconds: int _endpoint_negative_cache_ttl_seconds: int _endpoint_locks: dict[tuple[Literal["get", "post"], str], asyncio.Lock] @@ -39,15 +43,13 @@ class ClientEndpointMixin: _verify_ssl: bool _endpoint_checked_at: dict[tuple[Literal["get", "post"], str], float] - async def _get_optional( - self, path: str - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: ... + async def _get_optional(self, path: str) -> CategoryResult[object]: ... async def _post_optional( self, path: str, payload: MutableMapping[str, Any] | None = None, - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: ... + ) -> CategoryResult[object]: ... async def _safe_dict_get(self, path: str) -> dict[str, Any]: ... @@ -158,6 +160,10 @@ def _is_post_endpoint_probe_blocked(self, path: str) -> bool: return True return False + def _is_read_only_post_endpoint(self, path: str) -> bool: + """Return whether a POST endpoint is explicitly approved as read-only.""" + return path in self._OPTIONAL_POST_ENDPOINTS or path in self._OPTIONAL_POST_CACHE_PATHS + def _get_endpoint_cache_key(self, method: str, path: str) -> tuple[Literal["get", "post"], str]: """Build an internal endpoint cache key for method and path.""" if method == "post": @@ -174,10 +180,10 @@ def _get_endpoint_cache_ttl_seconds( self, method: str, path: str, - is_available: bool, + endpoint_state: EndpointAvailabilityState, ) -> int: """Return the endpoint cache TTL based on method/path and probe outcome.""" - if is_available is False and self._is_optional_endpoint(method, path): + if endpoint_state == "missing" and self._is_optional_endpoint(method, path): return self._endpoint_negative_cache_ttl_seconds return self._endpoint_cache_ttl_seconds @@ -185,11 +191,11 @@ def _is_endpoint_cache_fresh( self, method: str, path: str, - is_available: bool, + endpoint_state: EndpointAvailabilityState, checked_at: float, ) -> bool: """Return whether a cached availability result is still fresh.""" - ttl_seconds = self._get_endpoint_cache_ttl_seconds(method, path, is_available) + ttl_seconds = self._get_endpoint_cache_ttl_seconds(method, path, endpoint_state) return monotonic() - checked_at < ttl_seconds def _get_cached_endpoint_availability( @@ -197,7 +203,7 @@ def _get_cached_endpoint_availability( method: str, path: str, force_refresh: bool, - ) -> bool | None: + ) -> EndpointAvailabilityState | None: """Return cached optional endpoint state when valid and not expired. Args: @@ -206,7 +212,8 @@ def _get_cached_endpoint_availability( force_refresh (bool): Whether to bypass cached availability. Returns: - bool | None: Cached availability state when cache is fresh, or ``None`` + EndpointAvailabilityState | None: Cached availability state when cache + is fresh, or ``None`` when cache is missing, stale, or bypassed. """ if force_refresh: @@ -232,7 +239,11 @@ def _log_endpoint_transition( """Log a concise optional endpoint cache transition.""" old_value = self._endpoint_availability.get(cache_key) old_state = ( - "available" if old_value is True else "missing" if old_value is False else "unknown" + "available" + if old_value == "available" + else "missing" + if old_value == "missing" + else "unknown" ) _LOGGER.debug( "Optional endpoint cache transition %s %s: %s -> %s (%s)", @@ -250,7 +261,7 @@ def _refresh_positive_endpoint_observation( ) -> None: """Refresh a registered positive optional endpoint observation.""" self._log_endpoint_transition(cache_key, "available", reason) - self._endpoint_availability[cache_key] = True + self._endpoint_availability[cache_key] = "available" self._endpoint_checked_at[cache_key] = monotonic() self._optional_endpoint_missing_pending_confirmation.discard(cache_key) @@ -261,8 +272,8 @@ def _invalidate_endpoint_observation( ) -> None: """Invalidate an exact optional observation and mark it pending confirmation.""" self._log_endpoint_transition(cache_key, "pending", reason) - self._endpoint_availability.pop(cache_key, None) - self._endpoint_checked_at.pop(cache_key, None) + self._endpoint_availability[cache_key] = "pending" + self._endpoint_checked_at[cache_key] = monotonic() self._optional_endpoint_missing_pending_confirmation.add(cache_key) def _store_confirmed_negative_endpoint_observation( @@ -272,7 +283,7 @@ def _store_confirmed_negative_endpoint_observation( ) -> None: """Store a confirmed optional endpoint absence with the negative TTL.""" self._log_endpoint_transition(cache_key, "missing", reason) - self._endpoint_availability[cache_key] = False + self._endpoint_availability[cache_key] = "missing" self._endpoint_checked_at[cache_key] = monotonic() self._optional_endpoint_missing_pending_confirmation.discard(cache_key) @@ -309,31 +320,27 @@ async def _is_endpoint_available( return False cache_key = self._get_endpoint_cache_key(normalized_method, path) - cached_is_available = self._endpoint_availability.get(cache_key) + cached_state = self._endpoint_availability.get(cache_key) cached_at = self._endpoint_checked_at.get(cache_key) if ( not force_refresh - and cached_is_available is not None + and cached_state is not None and cached_at is not None - and self._is_endpoint_cache_fresh( - normalized_method, path, cached_is_available, cached_at - ) + and self._is_endpoint_cache_fresh(normalized_method, path, cached_state, cached_at) ): - return cached_is_available + return cached_state == "available" cache_lock = self._endpoint_locks.setdefault(cache_key, asyncio.Lock()) async with cache_lock: - cached_is_available = self._endpoint_availability.get(cache_key) + cached_state = self._endpoint_availability.get(cache_key) cached_at = self._endpoint_checked_at.get(cache_key) if ( not force_refresh - and cached_is_available is not None + and cached_state is not None and cached_at is not None - and self._is_endpoint_cache_fresh( - normalized_method, path, cached_is_available, cached_at - ) + and self._is_endpoint_cache_fresh(normalized_method, path, cached_state, cached_at) ): - return cached_is_available + return cached_state == "available" self._rest_api_query_count += 1 url = f"{self._url}{path}" @@ -349,11 +356,11 @@ async def _is_endpoint_available( ) as response: checked_at = monotonic() if response.ok: - self._endpoint_availability[cache_key] = True + self._endpoint_availability[cache_key] = "available" self._endpoint_checked_at[cache_key] = checked_at return True if response.status == 404: - self._endpoint_availability[cache_key] = False + self._endpoint_availability[cache_key] = "missing" self._endpoint_checked_at[cache_key] = checked_at return False @@ -417,7 +424,7 @@ async def _is_post_endpoint_available( """ if not isinstance(path, str) or not path: return None - if self._is_post_endpoint_probe_blocked(path): + if not self._is_read_only_post_endpoint(path) or self._is_post_endpoint_probe_blocked(path): _LOGGER.debug("POST endpoint availability probe blocked for unsafe path: %s", path) return None return await self._is_endpoint_available(path, method="post", force_refresh=force_refresh) @@ -427,7 +434,7 @@ async def _check_optional_get_endpoint( path: str, *, force_refresh: bool = False, - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + ) -> CategoryResult[object]: """Request an explicitly optional GET and reconcile its cache observation.""" return await self._check_optional_endpoint( method="get", @@ -444,7 +451,7 @@ async def _check_optional_post_endpoint( cache_path: str | None = None, *, force_refresh: bool = False, - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + ) -> CategoryResult[object]: """Request an explicitly read-only optional POST and reconcile its cache.""" resolved_cache_path = cache_path or path expected_cache_path = self._OPTIONAL_POST_CACHE_PATHS.get(path, path) @@ -455,7 +462,7 @@ async def _check_optional_post_endpoint( resolved_cache_path, expected_cache_path, ) - return "unavailable", {} + return CategoryResult({}, "transient", False) return await self._check_optional_endpoint( method="post", path=path, @@ -472,7 +479,7 @@ async def _check_optional_endpoint( cache_path: str, payload: MutableMapping[str, Any] | None, force_refresh: bool, - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + ) -> CategoryResult[object]: """Run a live optional probe and reconcile the optional endpoint cache key. Args: @@ -485,11 +492,11 @@ async def _check_optional_endpoint( force_refresh (bool): Whether to bypass stale/confirmed cache state. Returns: - tuple[Literal["available", "malformed", "missing", "unavailable"], object]: - ``("available", payload)`` or ``("malformed", payload)`` when the - request is reachable and can be interpreted as a response; ``("missing", {})`` only after a - confirmed 404 path; ``("unavailable", {})`` for transient failures - or unregistered optional endpoints. + CategoryResult: ``("available", payload)`` or ``("malformed", payload)`` + when the request is reachable and can be interpreted as a response, + ``("missing", {})`` only after a confirmed 404 path, and + ``("unavailable", {})`` for transient failures or unregistered + optional endpoints. Notes: The method only short-circuits via a fresh confirmed-negative cache @@ -498,28 +505,28 @@ async def _check_optional_endpoint( """ if not path or not self._is_optional_endpoint(method, cache_path): _LOGGER.debug("Unregistered optional endpoint: %s %s", method.upper(), path) - return "unavailable", {} + return CategoryResult({}, "transient", False) cache_key = self._get_endpoint_cache_key(method, cache_path) cache_lock = self._endpoint_locks.setdefault(cache_key, asyncio.Lock()) async with cache_lock: - had_confirmed_negative = self._endpoint_availability.get(cache_key) is False + had_confirmed_negative = self._endpoint_availability.get(cache_key) == "missing" cached_state = self._get_cached_endpoint_availability(method, cache_path, force_refresh) - if cached_state is False: - return "missing", {} + if cached_state == "missing": + return CategoryResult({}, "missing", True) was_pending = cache_key in self._optional_endpoint_missing_pending_confirmation if method == "post": - optional_state, response_payload = await self._post_optional(path, payload) + optional_result = CategoryResult.coerce(await self._post_optional(path, payload)) else: - optional_state, response_payload = await self._get_optional(path) + optional_result = CategoryResult.coerce(await self._get_optional(path)) - if optional_state in {"available", "malformed"}: + if optional_result.state in {"available", "malformed"}: self._refresh_positive_endpoint_observation(cache_key, f"real_{method}_success") - return optional_state, response_payload + return optional_result - if optional_state == "unavailable": - return "unavailable", {} + if optional_result.state == "transient": + return optional_result if not was_pending and not had_confirmed_negative: self._invalidate_endpoint_observation(cache_key, f"real_{method}_404") @@ -529,13 +536,14 @@ async def _check_optional_endpoint( "Skipping optional endpoint confirmation because firmware status endpoint is unavailable: %s", cache_path, ) - return "unavailable", {} + return CategoryResult({}, "transient", False) if was_pending or had_confirmed_negative: self._store_confirmed_negative_endpoint_observation( cache_key, f"confirmed_{method}_404" ) - return "missing", {} + return CategoryResult({}, "missing", True) + return CategoryResult({}, "pending", False) async def _is_core_firmware_endpoint_healthy(self) -> bool: """Return whether a fresh control request proves the router API is healthy. diff --git a/aiopnsense/client_queue.py b/aiopnsense/client_queue.py index 9563754..9aa8fe8 100644 --- a/aiopnsense/client_queue.py +++ b/aiopnsense/client_queue.py @@ -4,6 +4,7 @@ from collections.abc import MutableMapping import inspect from typing import TYPE_CHECKING, Any, Literal, cast +from ._typing import CategoryResult from .exceptions import OPNsenseError, _map_opnsense_exception from .helpers import _LOGGER @@ -41,10 +42,7 @@ async def _do_optional_get( self, path: str, caller: str = "Unknown", - ) -> tuple[ - Literal["available", "malformed", "missing", "unavailable"], - object, - ]: + ) -> CategoryResult[object]: """Execute a queued optional GET request.""" ... @@ -53,10 +51,7 @@ async def _do_optional_post( path: str, payload: MutableMapping[str, Any] | None = None, caller: str = "Unknown", - ) -> tuple[ - Literal["available", "malformed", "missing", "unavailable"], - object, - ]: + ) -> CategoryResult[object]: """Execute a queued optional read-only POST request.""" ... @@ -146,12 +141,10 @@ async def _get(self, path: str) -> MutableMapping[str, Any] | list | None: """ return await self._queue_request("get", path) - async def _get_optional( - self, path: str - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + async def _get_optional(self, path: str) -> CategoryResult[object]: """Queue an optional GET request and return the envelope response.""" return cast( - tuple[Literal["available", "malformed", "missing", "unavailable"], object], + CategoryResult[object], await self._queue_request("optional_get", path), ) @@ -159,13 +152,10 @@ async def _post_optional( self, path: str, payload: MutableMapping[str, Any] | None = None, - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + ) -> CategoryResult[object]: """Queue an optional read-only POST and return its envelope response.""" return cast( - tuple[ - Literal["available", "malformed", "missing", "unavailable"], - object, - ], + CategoryResult[object], await self._queue_request("optional_post", path, payload), ) diff --git a/aiopnsense/client_transport.py b/aiopnsense/client_transport.py index dd74d29..ba719c6 100644 --- a/aiopnsense/client_transport.py +++ b/aiopnsense/client_transport.py @@ -7,6 +7,7 @@ import aiohttp +from ._typing import CategoryResult from .const import DEFAULT_REQUEST_TIMEOUT_SECONDS from .exceptions import _map_opnsense_exception, _opnsense_http_error from .helpers import _LOGGER @@ -321,9 +322,7 @@ async def _do_get( return None - async def _do_optional_get( - self, path: str, caller: str = "Unknown" - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + async def _do_optional_get(self, path: str, caller: str = "Unknown") -> CategoryResult[object]: """Execute an optional GET request immediately. Args: @@ -347,21 +346,23 @@ async def _do_optional_get( _LOGGER.debug("[optional_get] Response %s: %s", response.status, response.reason) if response.ok: try: - return "available", await response.json(content_type=None) + return CategoryResult( + await response.json(content_type=None), "available", True + ) except (ValueError, UnicodeDecodeError) as err: _LOGGER.debug( "Optional GET endpoint returned malformed JSON for %s: %s", path, err, ) - return "malformed", {} + return CategoryResult({}, "malformed", True) if response.status == 404: _LOGGER.debug( "Optional GET endpoint unavailable (HTTP 404). Path: %s (called by %s)", path, caller, ) - return "missing", {} + return CategoryResult({}, "missing", True) if response.status == 403: _LOGGER.error( "Permission Error in optional_get (called by %s). Path: %s. Ensure the OPNsense user connected to HA has appropriate access. Recommend full admin access", @@ -387,14 +388,14 @@ async def _do_optional_get( if self._throw_errors: raise _map_opnsense_exception(e) from e - return "unavailable", {} + return CategoryResult({}, "transient", False) async def _do_optional_post( self, path: str, payload: MutableMapping[str, Any] | None = None, caller: str = "Unknown", - ) -> tuple[Literal["available", "malformed", "missing", "unavailable"], object]: + ) -> CategoryResult[object]: """Execute an explicitly read-only optional POST immediately. Args: @@ -419,21 +420,23 @@ async def _do_optional_post( _LOGGER.debug("[optional_post] Response %s: %s", response.status, response.reason) if response.ok: try: - return "available", await response.json(content_type=None) + return CategoryResult( + await response.json(content_type=None), "available", True + ) except (ValueError, UnicodeDecodeError) as err: _LOGGER.debug( "Optional POST endpoint returned malformed JSON for %s: %s", path, err, ) - return "malformed", {} + return CategoryResult({}, "malformed", True) if response.status == 404: _LOGGER.debug( "Optional POST endpoint unavailable (HTTP 404). Path: %s (called by %s)", path, caller, ) - return "missing", {} + return CategoryResult({}, "missing", True) if response.status == 403: _LOGGER.error( "Permission Error in optional_post (called by %s). Path: %s. Ensure the OPNsense user connected to HA has appropriate access. Recommend full admin access", @@ -459,7 +462,7 @@ async def _do_optional_post( if self._throw_errors: raise _map_opnsense_exception(err) from err - return "unavailable", {} + return CategoryResult({}, "transient", False) def _normalize_timeout_seconds(self, timeout_seconds: float | None) -> float: """Normalize per-call timeout values to a positive float in seconds. diff --git a/aiopnsense/dhcp.py b/aiopnsense/dhcp.py index bd920f8..3ab5f60 100644 --- a/aiopnsense/dhcp.py +++ b/aiopnsense/dhcp.py @@ -2,9 +2,9 @@ from collections.abc import MutableMapping from datetime import datetime, tzinfo -from typing import Any +from typing import Any, Literal -from ._typing import AiopnsenseClientProtocol +from ._typing import AiopnsenseClientProtocol, CategoryResult, CategoryState from .helpers import ( _LOGGER, _log_errors, @@ -123,6 +123,12 @@ async def get_dhcp_leases(self, opnsense_tz: tzinfo | None = None) -> dict[str, include address, hostname, interface, type, MAC, and expiration when available. """ + return (await self.get_dhcp_leases_result(opnsense_tz=opnsense_tz)).data + + async def get_dhcp_leases_result( + self, opnsense_tz: tzinfo | None = None + ) -> CategoryResult[dict[str, Any]]: + """Return DHCP leases with authority derived from every applicable source.""" if opnsense_tz is None: opnsense_tz = await self._get_opnsense_timezone() leases_raw: list = await self._get_kea_dhcpv4_leases(opnsense_tz=opnsense_tz) @@ -159,7 +165,68 @@ async def get_dhcp_leases(self, opnsense_tz: tzinfo | None = None) -> dict[str, "lease_interfaces": sorted_lease_interfaces, "leases": sorted_leases, } - return dhcp_leases + source_states = await self._get_dhcp_source_states() + aggregate_state, authoritative = self._aggregate_dhcp_source_states(source_states) + return CategoryResult(dhcp_leases, aggregate_state, authoritative) + + async def _get_dhcp_source_states(self) -> list[CategoryState]: + """Return observed availability states for DHCP lease providers.""" + isc_v4 = self._observed_endpoint_variant( + ISC_DHCPV4_LEASES_SEARCH_ENDPOINT, + ISC_DHCPV4_LEASES_SEARCH_CAMELCASE_ENDPOINT, + ) + isc_v6 = self._observed_endpoint_variant( + ISC_DHCPV6_LEASES_SEARCH_ENDPOINT, + ISC_DHCPV6_LEASES_SEARCH_CAMELCASE_ENDPOINT, + ) + paths = ( + KEA_LEASES4_SEARCH_ENDPOINT, + KEA_LEASES6_SEARCH_ENDPOINT, + DNSMASQ_LEASES_SEARCH_ENDPOINT, + isc_v4, + isc_v6, + ) + states: list[CategoryState] = [] + for path in paths: + cache_key: tuple[Literal["get", "post"], str] = ("get", path) + state = self._endpoint_availability.get(cache_key) + if state == "available": + states.append("available") + elif state == "missing": + states.append("missing") + elif ( + state == "pending" + or cache_key in self._optional_endpoint_missing_pending_confirmation + ): + states.append("pending") + else: + states.append("transient") + return states + + def _observed_endpoint_variant(self, snake_case_path: str, camel_case_path: str) -> str: + """Return the endpoint variant represented in the availability observations.""" + for path in (snake_case_path, camel_case_path): + key = ("get", path) + if key in self._endpoint_availability or ( + key in self._optional_endpoint_missing_pending_confirmation + ): + return path + return snake_case_path if self._use_snake_case is not False else camel_case_path + + @staticmethod + def _aggregate_dhcp_source_states( + source_states: list[CategoryState], + ) -> tuple[CategoryState, bool]: + """Combine provider states without treating absent plugins as uncertainty.""" + if "pending" in source_states: + return "pending", False + if "transient" in source_states: + return "transient", False + if "malformed" in source_states: + return "malformed", True + if "available" in source_states: + return "available", True + return "missing", True async def _get_kea_interfaces(self) -> dict[str, Any]: """Return interfaces selected for Kea DHCPv4. diff --git a/aiopnsense/smart.py b/aiopnsense/smart.py index 9ae6e2a..4ddb259 100644 --- a/aiopnsense/smart.py +++ b/aiopnsense/smart.py @@ -3,7 +3,7 @@ from collections.abc import MutableMapping from typing import Any -from ._typing import AiopnsenseClientProtocol +from ._typing import AiopnsenseClientProtocol, CategoryResult from .helpers import _LOGGER, _log_errors SMART_SERVICE_LIST_ENDPOINT = "/api/smart/service/list" @@ -21,19 +21,28 @@ async def get_smart(self) -> list[dict[str, Any]]: Returns: list[dict[str, Any]]: SMART device rows returned by the detailed API. """ - list_status, smart_info = await self._check_optional_post_endpoint( - SMART_SERVICE_DETAIL_ENDPOINT, - cache_path=SMART_SERVICE_LIST_ENDPOINT, + return (await self.get_smart_result()).data + + async def get_smart_result(self) -> CategoryResult[list[dict[str, Any]]]: + """Return SMART device data with authoritative availability metadata.""" + result = CategoryResult.coerce( + await self._check_optional_post_endpoint( + SMART_SERVICE_DETAIL_ENDPOINT, + cache_path=SMART_SERVICE_LIST_ENDPOINT, + ) ) - if list_status != "available" or not isinstance(smart_info, MutableMapping): + if result.state != "available": _LOGGER.debug("SMART plugin unavailable") - return [] + return CategoryResult([], result.state, result.authoritative) + smart_info = result.data + if not isinstance(smart_info, MutableMapping): + return CategoryResult([], "malformed", True) devices = smart_info.get("devices", []) if not isinstance(devices, list): _LOGGER.debug( "Discarding SMART devices payload because devices is not a list: %r", devices ) - return [] + return CategoryResult([], "malformed", True) smart_devices: list[dict[str, Any]] = [] for device in devices: if not isinstance(device, MutableMapping): @@ -48,7 +57,7 @@ async def get_smart(self) -> list[dict[str, Any]]: ) continue smart_devices.append(dict(device)) - return smart_devices + return CategoryResult(smart_devices, "available", True) @_log_errors async def get_smart_info(self, device: str, info_type: str = "a") -> dict[str, Any]: diff --git a/aiopnsense/speedtest.py b/aiopnsense/speedtest.py index c7019ee..966c106 100644 --- a/aiopnsense/speedtest.py +++ b/aiopnsense/speedtest.py @@ -17,7 +17,7 @@ from collections.abc import MutableMapping from typing import Any -from ._typing import AiopnsenseClientProtocol +from ._typing import AiopnsenseClientProtocol, CategoryResult from .helpers import _LOGGER, _log_errors, try_to_float, try_to_int SPEEDTEST_SHOW_LOG_ENDPOINT = "/api/speedtest/service/showlog" @@ -157,13 +157,14 @@ async def run_speedtest(self) -> dict[str, Any]: endpoint, or an empty mapping when the plugin endpoint is unavailable or cannot be safely invoked. """ - optional_state, _payload = await self._check_optional_get_endpoint( - SPEEDTEST_SHOW_LOG_ENDPOINT + probe_result = CategoryResult.coerce( + await self._check_optional_get_endpoint(SPEEDTEST_SHOW_LOG_ENDPOINT) ) + optional_state = probe_result.state if optional_state == "missing": _LOGGER.debug("Speedtest not installed") return {} - if optional_state == "unavailable": + if optional_state in {"pending", "transient"}: _LOGGER.debug("Speedtest temporarily unavailable") return {} if optional_state == "malformed": diff --git a/aiopnsense/unbound.py b/aiopnsense/unbound.py index b62cea2..1a6a709 100644 --- a/aiopnsense/unbound.py +++ b/aiopnsense/unbound.py @@ -5,7 +5,7 @@ import aiohttp -from ._typing import AiopnsenseClientProtocol +from ._typing import AiopnsenseClientProtocol, CategoryResult from .const import LEGACY_UNBOUND_BLOCKLIST_FIRMWARE from .exceptions import OPNsenseError from .helpers import _LOGGER, _log_errors, api_value_matches, firmware_is_at_least @@ -173,25 +173,35 @@ async def get_unbound_blocklist(self) -> dict[str, Any]: set to the regular DNSBL settings. Returns an empty mapping when the endpoint is unavailable or malformed. """ + return (await self.get_unbound_blocklist_result()).data + + async def get_unbound_blocklist_result(self) -> CategoryResult[dict[str, Any]]: + """Return Unbound blocklists with authoritative availability metadata.""" use_legacy = await self._uses_legacy_unbound_blocklist() if use_legacy is not False: _LOGGER.debug( "Getting Unbound regular blocklists for OPNsense < %s or when firmware detection is unavailable", LEGACY_UNBOUND_BLOCKLIST_FIRMWARE, ) - return {"legacy": await self._get_unbound_blocklist_legacy()} + legacy = await self._get_unbound_blocklist_legacy() + if not legacy: + return CategoryResult({"legacy": {}}, "malformed", True) + return CategoryResult({"legacy": legacy}, "available", True) - dnsbl_status, dnsbl_raw = await self._check_optional_get_endpoint( - UNBOUND_SETTINGS_SEARCH_DNSBL_ENDPOINT + result = CategoryResult.coerce( + await self._check_optional_get_endpoint(UNBOUND_SETTINGS_SEARCH_DNSBL_ENDPOINT) ) - if dnsbl_status != "available": + if result.state != "available": _LOGGER.debug("Unbound DNSBL endpoint unavailable") - return {} + return CategoryResult({}, result.state, result.authoritative) + dnsbl_raw = result.data if not isinstance(dnsbl_raw, MutableMapping): - return {} + return CategoryResult({}, "malformed", True) dnsbl_rows = dnsbl_raw.get("rows", []) - if not isinstance(dnsbl_rows, list) or not len(dnsbl_rows) > 0: - return {} + if not isinstance(dnsbl_rows, list): + return CategoryResult({}, "malformed", True) + if not dnsbl_rows: + return CategoryResult({}, "available", True) dnsbl_full: dict[str, Any] = {} for dnsbl in dnsbl_rows: if not isinstance(dnsbl, MutableMapping): @@ -199,7 +209,7 @@ async def get_unbound_blocklist(self) -> dict[str, Any]: if dnsbl.get("uuid"): dnsbl_full.update({dnsbl["uuid"]: dnsbl}) _LOGGER.debug("[get_unbound_blocklist] dnsbl_full length: %s", len(dnsbl_full)) - return dnsbl_full + return CategoryResult(dnsbl_full, "available", True) async def _toggle_unbound_blocklist(self, set_state: bool, uuid: str | None) -> bool: """Enable or disable one extended Unbound DNSBL entry. diff --git a/aiopnsense/vnstat.py b/aiopnsense/vnstat.py index 5424161..8e91219 100644 --- a/aiopnsense/vnstat.py +++ b/aiopnsense/vnstat.py @@ -7,7 +7,7 @@ import re from typing import Any -from ._typing import AiopnsenseClientProtocol +from ._typing import AiopnsenseClientProtocol, CategoryResult from .helpers import _LOGGER, _log_errors, normalize_lookup_token, try_to_float _VSTAT_HEADER_RE = re.compile( @@ -106,10 +106,20 @@ async def get_vnstat(self) -> MutableMapping[str, Any]: convenience byte counters for today, this month, yesterday, last month, and the last complete hour. """ - hourly_status, hourly_raw = await self._check_optional_get_endpoint(VNSTAT_HOURLY_ENDPOINT) - if hourly_status != "available" or not isinstance(hourly_raw, MutableMapping): + return (await self.get_vnstat_result()).data + + async def get_vnstat_result(self) -> CategoryResult[MutableMapping[str, Any]]: + """Return summarized vnStat data with authoritative availability metadata.""" + result = CategoryResult.coerce( + await self._check_optional_get_endpoint(VNSTAT_HOURLY_ENDPOINT) + ) + empty: MutableMapping[str, Any] = {"interfaces": {}, "interface_count": 0} + if result.state != "available": _LOGGER.debug("vnStat not installed") - return {"interfaces": {}, "interface_count": 0} + return CategoryResult(empty, result.state, result.authoritative) + hourly_raw = result.data + if not isinstance(hourly_raw, MutableMapping): + return CategoryResult(empty, "malformed", True) opnsense_tz = await self._get_opnsense_timezone() hourly = self._parse_vnstat_payload(hourly_raw, expected_period="hourly") daily = await self._fetch_vnstat_for(VNSTAT_DAILY_ENDPOINT, "daily") @@ -151,10 +161,11 @@ async def get_vnstat(self) -> MutableMapping[str, Any]: "monthly": rows_monthly, "metrics": metrics, } - return { - "interfaces": interface_data, - "interface_count": len(interface_names), - } + return CategoryResult( + {"interfaces": interface_data, "interface_count": len(interface_names)}, + "available", + True, + ) def _parse_vnstat_payload( self, payload: MutableMapping[str, Any], expected_period: str diff --git a/tests/test_category_result.py b/tests/test_category_result.py new file mode 100644 index 0000000..1cf7fdb --- /dev/null +++ b/tests/test_category_result.py @@ -0,0 +1,39 @@ +"""Tests for authoritative optional-category result contracts.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from aiopnsense import CategoryResult, CategoryState + + +def test_category_result_is_exported_immutable_and_generic() -> None: + """The public result envelope is frozen, slotted, and carries typed state.""" + state: CategoryState = "available" + result = CategoryResult([1], state, True) + + assert result.data == [1] + assert result.state == "available" + assert result.authoritative is True + assert not hasattr(result, "__dict__") + with pytest.raises(FrozenInstanceError): + result.state = "missing" # type: ignore[misc] + + +@pytest.mark.parametrize( + ("states", "expected"), + [ + (["available", "missing"], ("available", True)), + (["missing", "missing"], ("missing", True)), + (["available", "pending"], ("pending", False)), + (["available", "transient"], ("transient", False)), + (["available", "malformed"], ("malformed", True)), + ], +) +def test_dhcp_source_authority_ignores_only_confirmed_inapplicable_sources( + states: list[CategoryState], expected: tuple[CategoryState, bool] +) -> None: + """Confirmed missing providers are inapplicable; uncertain providers are not.""" + from aiopnsense.dhcp import DHCPMixin + + assert DHCPMixin._aggregate_dhcp_source_states(states) == expected diff --git a/tests/test_client_endpoint.py b/tests/test_client_endpoint.py index 2492ddc..27263a8 100644 --- a/tests/test_client_endpoint.py +++ b/tests/test_client_endpoint.py @@ -106,7 +106,7 @@ def _get(*args: Any, **kwargs: Any) -> Any: session.get = _get try: - path = "/api/test/endpoint" + path = "/api/smart/service/list" cache_key = ("get", path) assert await client._is_get_endpoint_available(path) is False assert await client._is_get_endpoint_available(path) is False @@ -202,7 +202,7 @@ def _get( session.get = _get try: - path = "/api/test/endpoint" + path = "/api/smart/service/list" cache_key = ("get", path) with pytest.raises(OPNsenseSSLError): await client._is_get_endpoint_available(path) @@ -427,7 +427,7 @@ def _post(*args: object, **kwargs: object) -> FakeResponse: session.post = _post try: - path = "/api/test/endpoint" + path = "/api/smart/service/list" cache_key = ("post", path) assert await client._is_post_endpoint_available(path) is True assert await client._is_post_endpoint_available(path) is True @@ -469,7 +469,7 @@ def _post(*args: object, **kwargs: object) -> FakeResponse: session.post = _post try: - path = "/api/test/endpoint" + path = "/api/smart/service/list" cache_key = ("post", path) assert await client._is_post_endpoint_available(path) is False assert await client._is_post_endpoint_available(path) is False @@ -616,7 +616,7 @@ async def test_check_optional_get_endpoint_refreshes_positive_state_and_payload( assert cache_key in client._endpoint_checked_at assert client._endpoint_checked_at[cache_key] == 1002.0 assert client._endpoint_checked_at[cache_key] > 1000.0 - assert client._endpoint_availability[cache_key] is True + assert client._endpoint_availability[cache_key] == "available" assert client._get_optional.await_count == 2 finally: await client.async_close() @@ -644,10 +644,10 @@ async def test_check_optional_get_endpoint_missing_drops_cached_positive_without assert cache_key in client._endpoint_checked_at with caplog.at_level(logging.WARNING): - assert await client._check_optional_get_endpoint(path) == ("missing", {}) + assert await client._check_optional_get_endpoint(path) == ("pending", {}) - assert cache_key not in client._endpoint_availability - assert cache_key not in client._endpoint_checked_at + assert client._endpoint_availability[cache_key] == "pending" + assert cache_key in client._endpoint_checked_at assert cache_key in client._optional_endpoint_missing_pending_confirmation client._is_core_firmware_endpoint_healthy.assert_awaited_once_with() assert not any( @@ -673,7 +673,7 @@ async def test_check_optional_get_endpoint_rechecks_pending_and_caches_negative_ second_state, second_payload = await client._check_optional_get_endpoint(path) third_state, third_payload = await client._check_optional_get_endpoint(path) - assert first_state == "missing" + assert first_state == "pending" assert first_payload == {} assert second_state == "missing" assert second_payload == {} @@ -681,7 +681,7 @@ async def test_check_optional_get_endpoint_rechecks_pending_and_caches_negative_ assert third_payload == {} assert client._get_optional.await_count == 2 assert client._is_core_firmware_endpoint_healthy.await_count == 2 - assert client._endpoint_availability[cache_key] is False + assert client._endpoint_availability[cache_key] == "missing" assert cache_key in client._endpoint_checked_at # second pending confirmation should apply the optional negative-cache window @@ -700,7 +700,7 @@ async def test_check_optional_get_endpoint_stale_negative_recovers_after_ttl_exp client, _session = make_mock_session_client(make_client) path = "/api/speedtest/service/showlog" cache_key = ("get", path) - client._endpoint_availability[cache_key] = False + client._endpoint_availability[cache_key] = "missing" times = iter([1000.0, 1001.0]) monkeypatch.setattr( "aiopnsense.client_endpoint.monotonic", @@ -714,7 +714,7 @@ async def test_check_optional_get_endpoint_stale_negative_recovers_after_ttl_exp assert state == "available" assert payload == {"status": "recovered"} - assert client._endpoint_availability[cache_key] is True + assert client._endpoint_availability[cache_key] == "available" assert client._endpoint_checked_at[cache_key] == 1001.0 assert client._endpoint_checked_at[cache_key] != 1000.0 - ( DEFAULT_NEGATIVE_CACHE_TTL_SECONDS + 1 @@ -731,7 +731,7 @@ async def test_check_optional_get_endpoint_stale_negative_renews_with_one_probe( client, _session = make_mock_session_client(make_client) path = "/api/speedtest/service/showlog" cache_key = ("get", path) - client._endpoint_availability[cache_key] = False + client._endpoint_availability[cache_key] = "missing" client._endpoint_checked_at[cache_key] = 1000.0 - (DEFAULT_NEGATIVE_CACHE_TTL_SECONDS + 1) times = iter([1000.0, 1001.0]) monkeypatch.setattr("aiopnsense.client_endpoint.monotonic", lambda: next(times)) @@ -740,7 +740,7 @@ async def test_check_optional_get_endpoint_stale_negative_renews_with_one_probe( try: assert await client._check_optional_get_endpoint(path) == ("missing", {}) - assert client._endpoint_availability[cache_key] is False + assert client._endpoint_availability[cache_key] == "missing" assert client._endpoint_checked_at[cache_key] == 1001.0 assert cache_key not in client._optional_endpoint_missing_pending_confirmation client._get_optional.assert_awaited_once_with(path) @@ -758,7 +758,7 @@ async def test_check_optional_get_endpoint_force_refresh_allows_recovery_before_ path = "/api/speedtest/service/showstat" cache_key = ("get", path) client._get_optional = AsyncMock(return_value=("available", {"samples": 1})) - client._endpoint_availability[cache_key] = False + client._endpoint_availability[cache_key] = "missing" client._endpoint_checked_at[cache_key] = 100.0 client._optional_endpoint_missing_pending_confirmation.add(cache_key) @@ -768,7 +768,7 @@ async def test_check_optional_get_endpoint_force_refresh_allows_recovery_before_ assert state == "available" assert payload == {"samples": 1} assert cache_key in client._endpoint_availability - assert client._endpoint_availability[cache_key] is True + assert client._endpoint_availability[cache_key] == "available" assert cache_key not in client._optional_endpoint_missing_pending_confirmation assert client._get_optional.await_count == 1 finally: @@ -779,16 +779,16 @@ async def test_check_optional_get_endpoint_force_refresh_allows_recovery_before_ @pytest.mark.parametrize( ("state", "core_healthy", "expected_state", "expected_cached"), [ - ("missing", True, "missing", False), - ("missing", False, "unavailable", None), - ("unavailable", True, "unavailable", None), + ("missing", True, "missing", "missing"), + ("missing", False, "transient", None), + ("transient", True, "transient", None), ], ) async def test_check_optional_get_endpoint_force_refresh_preserves_confirmation_contract( state: str, core_healthy: bool, expected_state: str, - expected_cached: bool | None, + expected_cached: str | None, make_client: MakeClientFactory, ) -> None: """Force refresh bypasses cache freshness without bypassing confirmation.""" @@ -805,10 +805,10 @@ async def test_check_optional_get_endpoint_force_refresh_preserves_confirmation_ assert result_state == expected_state assert payload == {} if expected_cached is None: - assert cache_key not in client._endpoint_availability + assert client._endpoint_availability.get(cache_key) in {None, "pending"} assert cache_key in client._optional_endpoint_missing_pending_confirmation else: - assert client._endpoint_availability[cache_key] is expected_cached + assert client._endpoint_availability[cache_key] == expected_cached assert cache_key not in client._optional_endpoint_missing_pending_confirmation if state == "missing": client._is_core_firmware_endpoint_healthy.assert_awaited_once_with() @@ -831,15 +831,15 @@ async def test_check_optional_get_endpoint_pending_confirmation_does_not_cache_w try: first_state, first_payload = await client._check_optional_get_endpoint(path) - assert first_state == "unavailable" + assert first_state == "transient" assert cache_key in client._optional_endpoint_missing_pending_confirmation second_state, second_payload = await client._check_optional_get_endpoint(path) - assert second_state == "unavailable" + assert second_state == "transient" assert second_payload == {} assert cache_key in client._optional_endpoint_missing_pending_confirmation - assert cache_key not in client._endpoint_availability - assert cache_key not in client._endpoint_checked_at + assert client._endpoint_availability[cache_key] == "pending" + assert cache_key in client._endpoint_checked_at assert first_payload == {} assert second_payload == {} assert client._get_optional.await_count == 2 @@ -862,12 +862,12 @@ async def test_check_optional_get_endpoint_recovers_before_core_confirmation( client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=True) try: - assert await client._check_optional_get_endpoint(path) == ("missing", {}) + assert await client._check_optional_get_endpoint(path) == ("pending", {}) assert await client._check_optional_get_endpoint(path) == ( "available", {"recovered": True}, ) - assert client._endpoint_availability[cache_key] is True + assert client._endpoint_availability[cache_key] == "available" assert cache_key not in client._optional_endpoint_missing_pending_confirmation client._is_core_firmware_endpoint_healthy.assert_awaited_once_with() finally: @@ -888,7 +888,7 @@ def get(url: str, **_kwargs: Any) -> FakeResponse: return FakeResponse(status=200, ok=True) session.get = get - client._endpoint_availability[("get", "/api/core/firmware/status")] = False + client._endpoint_availability[("get", "/api/core/firmware/status")] = "missing" client._endpoint_checked_at[("get", "/api/core/firmware/status")] = monotonic() query_count = client._rest_api_query_count @@ -896,7 +896,7 @@ def get(url: str, **_kwargs: Any) -> FakeResponse: assert await client._is_core_firmware_endpoint_healthy() is True assert requested_urls == [f"{client._url}/api/core/firmware/status"] assert client._rest_api_query_count == query_count + 1 - assert client._endpoint_availability[("get", "/api/core/firmware/status")] is False + assert client._endpoint_availability[("get", "/api/core/firmware/status")] == "missing" finally: await client.async_close() @@ -940,17 +940,17 @@ async def test_check_optional_get_endpoint_cached_positive_not_mutated_by_transi client, _session = make_mock_session_client(make_client) path = "/api/speedtest/service/showlog" cache_key = ("get", path) - client._endpoint_availability[cache_key] = True + client._endpoint_availability[cache_key] = "available" client._endpoint_checked_at[cache_key] = monotonic() - client._get_optional = AsyncMock(return_value=("unavailable", {})) + client._get_optional = AsyncMock(return_value=("transient", {})) try: state, payload = await client._check_optional_get_endpoint(path) - assert state == "unavailable" + assert state == "transient" assert payload == {} - assert client._endpoint_availability[cache_key] is True + assert client._endpoint_availability[cache_key] == "available" assert cache_key in client._endpoint_checked_at assert cache_key not in client._optional_endpoint_missing_pending_confirmation finally: @@ -980,7 +980,7 @@ async def test_isc_dhcp_lease_paths_are_exact_optional_capabilities( "available", {"rows": []}, ) - assert client._endpoint_availability[("get", path)] is True + assert client._endpoint_availability[("get", path)] == "available" client._get_optional.assert_awaited_once_with(path) finally: await client.async_close() @@ -1003,7 +1003,7 @@ async def test_check_optional_post_endpoint_refreshes_positive_exact_cache_key( ) assert result_state == state assert payload == {"devices": []} - assert client._endpoint_availability[cache_key] is True + assert client._endpoint_availability[cache_key] == "available" assert ("post", "/api/smart/service/list/1") not in client._endpoint_availability client._post_optional.assert_awaited_once_with("/api/smart/service/list/1", None) finally: @@ -1022,13 +1022,13 @@ async def test_check_optional_post_endpoint_confirms_second_404_with_core_health try: assert await client._check_optional_post_endpoint( "/api/smart/service/info", payload={"device": "ada0"} - ) == ("missing", {}) + ) == ("pending", {}) assert cache_key in client._optional_endpoint_missing_pending_confirmation assert await client._check_optional_post_endpoint( "/api/smart/service/info", payload={"device": "ada0"} ) == ("missing", {}) - assert client._endpoint_availability[cache_key] is False + assert client._endpoint_availability[cache_key] == "missing" assert cache_key not in client._optional_endpoint_missing_pending_confirmation assert client._post_optional.await_count == 2 assert client._is_core_firmware_endpoint_healthy.await_count == 2 @@ -1044,7 +1044,7 @@ async def test_check_optional_post_endpoint_stale_negative_renews_with_one_probe client, _session = make_mock_session_client(make_client) path = "/api/smart/service/info" cache_key = ("post", path) - client._endpoint_availability[cache_key] = False + client._endpoint_availability[cache_key] = "missing" client._endpoint_checked_at[cache_key] = 1000.0 - (DEFAULT_NEGATIVE_CACHE_TTL_SECONDS + 1) times = iter([1000.0, 1001.0]) monkeypatch.setattr("aiopnsense.client_endpoint.monotonic", lambda: next(times)) @@ -1057,7 +1057,7 @@ async def test_check_optional_post_endpoint_stale_negative_renews_with_one_probe "missing", {}, ) - assert client._endpoint_availability[cache_key] is False + assert client._endpoint_availability[cache_key] == "missing" assert client._endpoint_checked_at[cache_key] == 1001.0 assert cache_key not in client._optional_endpoint_missing_pending_confirmation client._post_optional.assert_awaited_once_with(path, payload) @@ -1073,16 +1073,16 @@ async def test_check_optional_post_endpoint_transient_failure_preserves_positive """A non-404 SMART failure must not mutate the positive observation.""" client, _session = make_mock_session_client(make_client) cache_key = ("post", "/api/smart/service/info") - client._endpoint_availability[cache_key] = True + client._endpoint_availability[cache_key] = "available" checked_at = monotonic() client._endpoint_checked_at[cache_key] = checked_at - client._post_optional = AsyncMock(return_value=("unavailable", {})) + client._post_optional = AsyncMock(return_value=("transient", {})) try: assert await client._check_optional_post_endpoint("/api/smart/service/info") == ( "unavailable", {}, ) - assert client._endpoint_availability[cache_key] is True + assert client._endpoint_availability[cache_key] == "available" assert client._endpoint_checked_at[cache_key] == checked_at finally: await client.async_close() @@ -1099,7 +1099,7 @@ async def test_check_optional_post_endpoint_rejects_unregistered_mapping( assert await client._check_optional_post_endpoint( "/api/smart/service/list/1", cache_path="/api/smart/service/info", - ) == ("unavailable", {}) + ) == ("transient", {}) client._post_optional.assert_not_awaited() finally: await client.async_close() @@ -1213,7 +1213,7 @@ def _post(*args: object, **kwargs: object) -> FakeResponse: session.post = _post try: - path = "/api/core/firmware/changelog/26.1.1" + path = "/api/smart/service/info" assert await client._is_post_endpoint_available(path) is True assert calls == 1 assert ("post", path) in client._endpoint_checked_at From f29b5c62cd437b32f3373ea81b937a0dec5fb783 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 18:27:02 -0400 Subject: [PATCH 05/14] Validate optional category payload schemas --- aiopnsense/_typing.py | 17 ++- aiopnsense/client_base.py | 3 +- aiopnsense/client_endpoint.py | 8 +- aiopnsense/client_transport.py | 8 +- aiopnsense/dhcp.py | 170 ++++++++++++++++----------- aiopnsense/smart.py | 9 +- aiopnsense/unbound.py | 17 ++- aiopnsense/vnstat.py | 48 ++++++-- tests/test_category_result.py | 207 ++++++++++++++++++++++++++++++++- tests/test_dhcp.py | 38 +++--- 10 files changed, 402 insertions(+), 123 deletions(-) diff --git a/aiopnsense/_typing.py b/aiopnsense/_typing.py index 985901a..029114e 100644 --- a/aiopnsense/_typing.py +++ b/aiopnsense/_typing.py @@ -19,6 +19,11 @@ class CategoryResult[T]: state: CategoryState authoritative: bool + def __post_init__(self) -> None: + """Reject envelopes whose authority contradicts their state.""" + if self.authoritative is not (self.state == "available"): + raise ValueError("authoritative must be true exactly when state is 'available'") + @staticmethod def coerce(value: object) -> "CategoryResult[object]": """Normalize legacy internal tuple results during the contract migration.""" @@ -28,10 +33,8 @@ def coerce(value: object) -> "CategoryResult[object]": state: CategoryState | str = "transient" if value[0] == "unavailable" else value[0] if state in {"available", "pending", "missing", "transient", "malformed"}: typed_state: CategoryState = state - return CategoryResult( - value[1], typed_state, typed_state in {"available", "missing", "malformed"} - ) - return CategoryResult({}, "malformed", True) + return CategoryResult(value[1], typed_state, typed_state == "available") + return CategoryResult({}, "malformed", False) def __iter__(self) -> Iterator[object]: """Yield legacy state/data tuple values for internal compatibility.""" @@ -61,6 +64,7 @@ class AiopnsenseClientProtocol(Protocol): _endpoint_checked_at: dict[tuple[Literal["get", "post"], str], float] _endpoint_locks: dict[tuple[Literal["get", "post"], str], asyncio.Lock] _optional_endpoint_missing_pending_confirmation: set[tuple[Literal["get", "post"], str]] + _dhcp_source_states: list[CategoryState] async def _get(self, path: str) -> MutableMapping[str, Any] | list | None: ... @@ -121,6 +125,7 @@ async def _is_post_endpoint_available( async def _check_optional_get_endpoint( self, path: str, + cache_path: str | None = None, *, force_refresh: bool = False, ) -> CategoryResult[object]: ... @@ -143,3 +148,7 @@ async def get_unbound_blocklist_result(self) -> CategoryResult[dict[str, Any]]: async def get_dhcp_leases_result( self, opnsense_tz: tzinfo | None = None ) -> CategoryResult[dict[str, Any]]: ... + + async def get_arp_table_result( + self, resolve_hostnames: bool = False + ) -> CategoryResult[list[dict[str, Any]]]: ... diff --git a/aiopnsense/client_base.py b/aiopnsense/client_base.py index 8250a6e..67d5a05 100644 --- a/aiopnsense/client_base.py +++ b/aiopnsense/client_base.py @@ -13,7 +13,7 @@ from .client_transport import ClientTransportMixin from .const import DEFAULT_CACHE_TTL_SECONDS, DEFAULT_NEGATIVE_CACHE_TTL_SECONDS from .exceptions import OPNsenseInvalidArgument -from ._typing import EndpointAvailabilityState +from ._typing import CategoryState, EndpointAvailabilityState _UNSET: object = object() @@ -88,6 +88,7 @@ def __init__( self._optional_endpoint_missing_pending_confirmation: set[ tuple[Literal["get", "post"], str] ] = set() + self._dhcp_source_states: list[CategoryState] = [] positive_ttl = self._opts.get( "endpoint_positive_cache_ttl_seconds", DEFAULT_CACHE_TTL_SECONDS ) diff --git a/aiopnsense/client_endpoint.py b/aiopnsense/client_endpoint.py index e81989e..8fafe33 100644 --- a/aiopnsense/client_endpoint.py +++ b/aiopnsense/client_endpoint.py @@ -58,6 +58,7 @@ async def _safe_dict_get(self, path: str) -> dict[str, Any]: ... { "/api/speedtest/service/showlog", "/api/speedtest/service/showstat", + "/api/diagnostics/interface/search_arp", "/api/nut/diagnostics/upsstatus", "/api/dhcpv4/leases/search_lease", "/api/dhcpv4/leases/searchLease", @@ -432,6 +433,7 @@ async def _is_post_endpoint_available( async def _check_optional_get_endpoint( self, path: str, + cache_path: str | None = None, *, force_refresh: bool = False, ) -> CategoryResult[object]: @@ -439,7 +441,7 @@ async def _check_optional_get_endpoint( return await self._check_optional_endpoint( method="get", path=path, - cache_path=path, + cache_path=cache_path or path, payload=None, force_refresh=force_refresh, ) @@ -513,7 +515,7 @@ async def _check_optional_endpoint( had_confirmed_negative = self._endpoint_availability.get(cache_key) == "missing" cached_state = self._get_cached_endpoint_availability(method, cache_path, force_refresh) if cached_state == "missing": - return CategoryResult({}, "missing", True) + return CategoryResult({}, "missing", False) was_pending = cache_key in self._optional_endpoint_missing_pending_confirmation if method == "post": @@ -542,7 +544,7 @@ async def _check_optional_endpoint( self._store_confirmed_negative_endpoint_observation( cache_key, f"confirmed_{method}_404" ) - return CategoryResult({}, "missing", True) + return CategoryResult({}, "missing", False) return CategoryResult({}, "pending", False) async def _is_core_firmware_endpoint_healthy(self) -> bool: diff --git a/aiopnsense/client_transport.py b/aiopnsense/client_transport.py index ba719c6..67b2e12 100644 --- a/aiopnsense/client_transport.py +++ b/aiopnsense/client_transport.py @@ -355,14 +355,14 @@ async def _do_optional_get(self, path: str, caller: str = "Unknown") -> Category path, err, ) - return CategoryResult({}, "malformed", True) + return CategoryResult({}, "malformed", False) if response.status == 404: _LOGGER.debug( "Optional GET endpoint unavailable (HTTP 404). Path: %s (called by %s)", path, caller, ) - return CategoryResult({}, "missing", True) + return CategoryResult({}, "missing", False) if response.status == 403: _LOGGER.error( "Permission Error in optional_get (called by %s). Path: %s. Ensure the OPNsense user connected to HA has appropriate access. Recommend full admin access", @@ -429,14 +429,14 @@ async def _do_optional_post( path, err, ) - return CategoryResult({}, "malformed", True) + return CategoryResult({}, "malformed", False) if response.status == 404: _LOGGER.debug( "Optional POST endpoint unavailable (HTTP 404). Path: %s (called by %s)", path, caller, ) - return CategoryResult({}, "missing", True) + return CategoryResult({}, "missing", False) if response.status == 403: _LOGGER.error( "Permission Error in optional_post (called by %s). Path: %s. Ensure the OPNsense user connected to HA has appropriate access. Recommend full admin access", diff --git a/aiopnsense/dhcp.py b/aiopnsense/dhcp.py index 3ab5f60..ef945e6 100644 --- a/aiopnsense/dhcp.py +++ b/aiopnsense/dhcp.py @@ -2,7 +2,7 @@ from collections.abc import MutableMapping from datetime import datetime, tzinfo -from typing import Any, Literal +from typing import Any from ._typing import AiopnsenseClientProtocol, CategoryResult, CategoryState from .helpers import ( @@ -96,16 +96,34 @@ async def get_arp_table(self, resolve_hostnames: bool = False) -> list: including fields such as IP address, MAC address, interface, expiration, and entry type when provided by the endpoint. """ + return (await self.get_arp_table_result(resolve_hostnames=resolve_hostnames)).data + + async def get_arp_table_result( + self, resolve_hostnames: bool = False + ) -> CategoryResult[list[dict[str, Any]]]: + """Return ARP rows with schema-aware endpoint authority metadata.""" # [{'hostname': '?', 'ip-address': '', 'mac-address': '', 'interface': 'em0', 'expires': 1199, 'type': 'ethernet'}, ...] resolve_flag = "yes" if resolve_hostnames else "no" - if not await self._is_get_endpoint_available(ARP_TABLE_ENDPOINT): - _LOGGER.debug("ARP endpoint unavailable") - return [] - arp_endpoint_resolve = f"{ARP_TABLE_ENDPOINT}?resolve={resolve_flag}" - arp_table_info = await self._safe_dict_get(arp_endpoint_resolve) - arp_table: list = arp_table_info.get("rows", []) - return arp_table + result = CategoryResult.coerce( + await self._check_optional_get_endpoint( + arp_endpoint_resolve, + cache_path=ARP_TABLE_ENDPOINT, + ) + ) + if result.state != "available": + _LOGGER.debug("ARP endpoint unavailable") + return CategoryResult([], result.state, False) + arp_table_info = result.data + if not isinstance(arp_table_info, MutableMapping): + return CategoryResult([], "malformed", False) + rows = arp_table_info.get("rows", []) + if not isinstance(rows, list): + return CategoryResult([], "malformed", False) + arp_table = [dict(row) for row in rows if isinstance(row, MutableMapping)] + if len(arp_table) != len(rows): + return CategoryResult(arp_table, "malformed", False) + return CategoryResult(arp_table, "available", True) @_log_errors async def get_dhcp_leases(self, opnsense_tz: tzinfo | None = None) -> dict[str, Any]: @@ -129,6 +147,7 @@ async def get_dhcp_leases_result( self, opnsense_tz: tzinfo | None = None ) -> CategoryResult[dict[str, Any]]: """Return DHCP leases with authority derived from every applicable source.""" + self._dhcp_source_states = [] if opnsense_tz is None: opnsense_tz = await self._get_opnsense_timezone() leases_raw: list = await self._get_kea_dhcpv4_leases(opnsense_tz=opnsense_tz) @@ -165,53 +184,18 @@ async def get_dhcp_leases_result( "lease_interfaces": sorted_lease_interfaces, "leases": sorted_leases, } - source_states = await self._get_dhcp_source_states() + source_states = self._dhcp_source_states aggregate_state, authoritative = self._aggregate_dhcp_source_states(source_states) return CategoryResult(dhcp_leases, aggregate_state, authoritative) - async def _get_dhcp_source_states(self) -> list[CategoryState]: - """Return observed availability states for DHCP lease providers.""" - isc_v4 = self._observed_endpoint_variant( - ISC_DHCPV4_LEASES_SEARCH_ENDPOINT, - ISC_DHCPV4_LEASES_SEARCH_CAMELCASE_ENDPOINT, - ) - isc_v6 = self._observed_endpoint_variant( - ISC_DHCPV6_LEASES_SEARCH_ENDPOINT, - ISC_DHCPV6_LEASES_SEARCH_CAMELCASE_ENDPOINT, - ) - paths = ( - KEA_LEASES4_SEARCH_ENDPOINT, - KEA_LEASES6_SEARCH_ENDPOINT, - DNSMASQ_LEASES_SEARCH_ENDPOINT, - isc_v4, - isc_v6, - ) - states: list[CategoryState] = [] - for path in paths: - cache_key: tuple[Literal["get", "post"], str] = ("get", path) - state = self._endpoint_availability.get(cache_key) - if state == "available": - states.append("available") - elif state == "missing": - states.append("missing") - elif ( - state == "pending" - or cache_key in self._optional_endpoint_missing_pending_confirmation - ): - states.append("pending") - else: - states.append("transient") - return states - - def _observed_endpoint_variant(self, snake_case_path: str, camel_case_path: str) -> str: - """Return the endpoint variant represented in the availability observations.""" - for path in (snake_case_path, camel_case_path): - key = ("get", path) - if key in self._endpoint_availability or ( - key in self._optional_endpoint_missing_pending_confirmation - ): - return path - return snake_case_path if self._use_snake_case is not False else camel_case_path + def _record_dhcp_source_state(self, state: CategoryState) -> None: + """Record the schema-aware state of one DHCP provider.""" + self._dhcp_source_states.append(state) + + def _unavailable_dhcp_source_state(self, path: str) -> CategoryState: + """Resolve a failed source probe as confirmed missing or transient.""" + state = self._endpoint_availability.get(("get", path)) + return "missing" if state == "missing" else "transient" @staticmethod def _aggregate_dhcp_source_states( @@ -223,10 +207,10 @@ def _aggregate_dhcp_source_states( if "transient" in source_states: return "transient", False if "malformed" in source_states: - return "malformed", True + return "malformed", False if "available" in source_states: return "available", True - return "missing", True + return "missing", False async def _get_kea_interfaces(self) -> dict[str, Any]: """Return interfaces selected for Kea DHCPv4. @@ -322,11 +306,14 @@ async def _get_kea_dhcp_leases( list: Normalized Kea lease entries for the supplied endpoint. """ if not await self._is_get_endpoint_available(lease_endpoint): + self._record_dhcp_source_state(self._unavailable_dhcp_source_state(lease_endpoint)) _LOGGER.debug("%s not installed", service_name) return [] response = await self._safe_dict_get(lease_endpoint) if not isinstance(response.get("rows", None), list): + self._record_dhcp_source_state("malformed") return [] + malformed = False res_info: list[Any] | None if reservation_endpoint is None or reservation_camelcase_endpoint is None: res_info = None @@ -341,6 +328,7 @@ async def _get_kea_dhcp_leases( else: res_resp = await self._safe_dict_get(selected_reservation_endpoint) if not isinstance(res_resp.get("rows", None), list): + malformed = True _LOGGER.debug( "%s reservation lookup returned invalid rows payload", service_name ) @@ -351,19 +339,26 @@ async def _get_kea_dhcp_leases( if res_info is not None: for res in res_info: if not isinstance(res, MutableMapping): + malformed = True continue if res.get("hw_address", None): reservations.update({res.get("hw_address"): res.get("ip_address", "")}) leases_info: list = response.get("rows", []) leases: list = [] for lease_info in leases_info: + if not isinstance(lease_info, MutableMapping): + malformed = True + continue if ( lease_info is None - or not isinstance(lease_info, MutableMapping) or not api_value_matches(lease_info.get("state"), "0") or (require_hardware_address and not lease_info.get("hwaddr", None)) ): continue + if not isinstance(lease_info.get("address"), str) or not isinstance( + lease_info.get("if_name"), str + ): + malformed = True lease: dict[str, Any] = {} lease["address"] = lease_info.get("address", None) lease["hostname"] = ( @@ -406,6 +401,7 @@ async def _get_kea_dhcp_leases( else: lease["expires"] = lease_info.get("expire", None) leases.append(lease) + self._record_dhcp_source_state("malformed" if malformed else "available") return leases def _keep_latest_leases(self, reservations: list[dict]) -> list[dict]: @@ -458,17 +454,23 @@ async def _get_dnsmasq_leases(self, opnsense_tz: tzinfo | None = None) -> list: ``type`` is derived from dnsmasq reservation metadata. """ if not await self._is_get_endpoint_available(DNSMASQ_LEASES_SEARCH_ENDPOINT): + self._record_dhcp_source_state( + self._unavailable_dhcp_source_state(DNSMASQ_LEASES_SEARCH_ENDPOINT) + ) _LOGGER.debug("Dnsmasq DHCP not installed") return [] response = await self._safe_dict_get(DNSMASQ_LEASES_SEARCH_ENDPOINT) leases_info: list = response.get("rows", []) if not isinstance(leases_info, list): + self._record_dhcp_source_state("malformed") return [] + malformed = any(not isinstance(row, MutableMapping) for row in leases_info) cleaned_leases = self._keep_latest_leases(leases_info) leases: list = [] for lease_info in cleaned_leases: if not isinstance(lease_info, MutableMapping): + malformed = True continue lease: dict[str, Any] = {} lease["address"] = lease_info.get("address", None) @@ -483,6 +485,9 @@ async def _get_dnsmasq_leases(self, opnsense_tz: tzinfo | None = None) -> list: if_name = lease_info.get("if_name", None) if not isinstance(if_name, str) or len(if_name) == 0: if_name = lease_info.get("if", None) + if not isinstance(lease_info.get("address"), str) or not isinstance(if_name, str): + malformed = True + continue lease["if_name"] = if_name if self._is_reserved_lease(lease_info.get("is_reserved")): lease["type"] = "static" @@ -505,6 +510,7 @@ async def _get_dnsmasq_leases(self, opnsense_tz: tzinfo | None = None) -> list: else: lease["expires"] = lease_info.get("expire", None) leases.append(lease) + self._record_dhcp_source_state("malformed" if malformed else "available") return leases async def _get_isc_dhcpv4_leases(self, opnsense_tz: tzinfo | None = None) -> list: @@ -522,22 +528,35 @@ async def _get_isc_dhcpv4_leases(self, opnsense_tz: tzinfo | None = None) -> lis snake_case_path=ISC_DHCPV4_LEASES_SEARCH_ENDPOINT, camel_case_path=ISC_DHCPV4_LEASES_SEARCH_CAMELCASE_ENDPOINT, ) - lease_status, response = await self._check_optional_get_endpoint(lease_endpoint) - if lease_status != "available" or not isinstance(response, MutableMapping): + source_result = CategoryResult.coerce( + await self._check_optional_get_endpoint(lease_endpoint) + ) + if source_result.state != "available": + self._record_dhcp_source_state(source_result.state) _LOGGER.debug("ISC DHCPv4 lease endpoint unavailable") return [] + response = source_result.data + if not isinstance(response, MutableMapping): + self._record_dhcp_source_state("malformed") + return [] leases_info: list = response.get("rows", []) if not isinstance(leases_info, list): + self._record_dhcp_source_state("malformed") return [] if opnsense_tz is None: opnsense_tz = await self._get_opnsense_timezone() leases: list = [] + malformed = False for lease_info in leases_info: - if ( - not isinstance(lease_info, MutableMapping) - or lease_info.get("state", "") != "active" - or not lease_info.get("mac", None) + if not isinstance(lease_info, MutableMapping): + malformed = True + continue + if lease_info.get("state", "") != "active" or not lease_info.get("mac", None): + continue + if not isinstance(lease_info.get("address"), str) or not isinstance( + lease_info.get("if"), str ): + malformed = True continue lease: dict[str, Any] = {} lease["address"] = lease_info.get("address", None) @@ -557,6 +576,7 @@ async def _get_isc_dhcpv4_leases(self, opnsense_tz: tzinfo | None = None) -> lis lease_info.get("ends", None), "%Y/%m/%d %H:%M:%S" ) except TypeError, ValueError: + malformed = True continue lease["expires"] = dt.replace(tzinfo=opnsense_tz) if lease["expires"] < datetime.now().astimezone(): @@ -564,6 +584,7 @@ async def _get_isc_dhcpv4_leases(self, opnsense_tz: tzinfo | None = None) -> lis else: lease["expires"] = lease_info.get("ends", None) leases.append(lease) + self._record_dhcp_source_state("malformed" if malformed else "available") return leases async def _get_isc_dhcpv6_leases(self, opnsense_tz: tzinfo | None = None) -> list: @@ -581,22 +602,35 @@ async def _get_isc_dhcpv6_leases(self, opnsense_tz: tzinfo | None = None) -> lis snake_case_path=ISC_DHCPV6_LEASES_SEARCH_ENDPOINT, camel_case_path=ISC_DHCPV6_LEASES_SEARCH_CAMELCASE_ENDPOINT, ) - lease_status, response = await self._check_optional_get_endpoint(lease_endpoint) - if lease_status != "available" or not isinstance(response, MutableMapping): + source_result = CategoryResult.coerce( + await self._check_optional_get_endpoint(lease_endpoint) + ) + if source_result.state != "available": + self._record_dhcp_source_state(source_result.state) _LOGGER.debug("ISC DHCPv6 lease endpoint unavailable") return [] + response = source_result.data + if not isinstance(response, MutableMapping): + self._record_dhcp_source_state("malformed") + return [] leases_info: list = response.get("rows", []) if not isinstance(leases_info, list): + self._record_dhcp_source_state("malformed") return [] if opnsense_tz is None: opnsense_tz = await self._get_opnsense_timezone() leases: list = [] + malformed = False for lease_info in leases_info: - if ( - not isinstance(lease_info, MutableMapping) - or lease_info.get("state", "") != "active" - or not lease_info.get("mac", None) + if not isinstance(lease_info, MutableMapping): + malformed = True + continue + if lease_info.get("state", "") != "active" or not lease_info.get("mac", None): + continue + if not isinstance(lease_info.get("address"), str) or not isinstance( + lease_info.get("if"), str ): + malformed = True continue lease: dict[str, Any] = {} lease["address"] = lease_info.get("address", None) @@ -616,6 +650,7 @@ async def _get_isc_dhcpv6_leases(self, opnsense_tz: tzinfo | None = None) -> lis lease_info.get("ends", None), "%Y/%m/%d %H:%M:%S" ) except TypeError, ValueError: + malformed = True continue lease["expires"] = dt.replace(tzinfo=opnsense_tz) if lease["expires"] < datetime.now().astimezone(): @@ -623,4 +658,5 @@ async def _get_isc_dhcpv6_leases(self, opnsense_tz: tzinfo | None = None) -> lis else: lease["expires"] = lease_info.get("ends", None) leases.append(lease) + self._record_dhcp_source_state("malformed" if malformed else "available") return leases diff --git a/aiopnsense/smart.py b/aiopnsense/smart.py index 4ddb259..e706d49 100644 --- a/aiopnsense/smart.py +++ b/aiopnsense/smart.py @@ -36,27 +36,32 @@ async def get_smart_result(self) -> CategoryResult[list[dict[str, Any]]]: return CategoryResult([], result.state, result.authoritative) smart_info = result.data if not isinstance(smart_info, MutableMapping): - return CategoryResult([], "malformed", True) + return CategoryResult([], "malformed", False) devices = smart_info.get("devices", []) if not isinstance(devices, list): _LOGGER.debug( "Discarding SMART devices payload because devices is not a list: %r", devices ) - return CategoryResult([], "malformed", True) + return CategoryResult([], "malformed", False) smart_devices: list[dict[str, Any]] = [] + malformed = False for device in devices: if not isinstance(device, MutableMapping): + malformed = True _LOGGER.debug( "Discarding SMART device row because item is not a mapping: %r", device ) continue ident = device.get("ident", "") if not isinstance(ident, str) or not ident.strip(): + malformed = True _LOGGER.debug( "Discarding SMART device row because ident is missing or invalid: %r", device ) continue smart_devices.append(dict(device)) + if malformed: + return CategoryResult(smart_devices, "malformed", False) return CategoryResult(smart_devices, "available", True) @_log_errors diff --git a/aiopnsense/unbound.py b/aiopnsense/unbound.py index 1a6a709..62bf242 100644 --- a/aiopnsense/unbound.py +++ b/aiopnsense/unbound.py @@ -185,7 +185,7 @@ async def get_unbound_blocklist_result(self) -> CategoryResult[dict[str, Any]]: ) legacy = await self._get_unbound_blocklist_legacy() if not legacy: - return CategoryResult({"legacy": {}}, "malformed", True) + return CategoryResult({"legacy": {}}, "malformed", False) return CategoryResult({"legacy": legacy}, "available", True) result = CategoryResult.coerce( @@ -196,19 +196,26 @@ async def get_unbound_blocklist_result(self) -> CategoryResult[dict[str, Any]]: return CategoryResult({}, result.state, result.authoritative) dnsbl_raw = result.data if not isinstance(dnsbl_raw, MutableMapping): - return CategoryResult({}, "malformed", True) + return CategoryResult({}, "malformed", False) dnsbl_rows = dnsbl_raw.get("rows", []) if not isinstance(dnsbl_rows, list): - return CategoryResult({}, "malformed", True) + return CategoryResult({}, "malformed", False) if not dnsbl_rows: return CategoryResult({}, "available", True) dnsbl_full: dict[str, Any] = {} + malformed = False for dnsbl in dnsbl_rows: if not isinstance(dnsbl, MutableMapping): + malformed = True continue - if dnsbl.get("uuid"): - dnsbl_full.update({dnsbl["uuid"]: dnsbl}) + uuid = dnsbl.get("uuid") + if not isinstance(uuid, str) or not uuid: + malformed = True + continue + dnsbl_full[uuid] = dict(dnsbl) _LOGGER.debug("[get_unbound_blocklist] dnsbl_full length: %s", len(dnsbl_full)) + if malformed: + return CategoryResult(dnsbl_full, "malformed", False) return CategoryResult(dnsbl_full, "available", True) async def _toggle_unbound_blocklist(self, set_state: bool, uuid: str | None) -> bool: diff --git a/aiopnsense/vnstat.py b/aiopnsense/vnstat.py index 8e91219..7455fe3 100644 --- a/aiopnsense/vnstat.py +++ b/aiopnsense/vnstat.py @@ -65,11 +65,27 @@ async def _fetch_vnstat_for(self, endpoint: str, expected_period: str) -> dict[s Returns: dict[str, Any]: Parsed payload or fallback empty mapping when endpoint is unavailable. """ - status, payload = await self._check_optional_get_endpoint(endpoint) - if status != "available" or not isinstance(payload, MutableMapping): + return (await self._fetch_vnstat_for_result(endpoint, expected_period)).data + + async def _fetch_vnstat_for_result( + self, endpoint: str, expected_period: str + ) -> CategoryResult[dict[str, Any]]: + """Fetch one vnStat period with transport and payload-schema metadata.""" + result = CategoryResult.coerce(await self._check_optional_get_endpoint(endpoint)) + empty = {"period": expected_period, "interfaces": {}} + if result.state != "available": _LOGGER.debug("vnStat %s endpoint unavailable", expected_period) - return {"period": expected_period, "interfaces": {}} - return self._parse_vnstat_payload(payload, expected_period=expected_period) + return CategoryResult(empty, result.state, False) + payload = result.data + if not isinstance(payload, MutableMapping) or not isinstance( + payload.get("response", ""), str + ): + return CategoryResult(empty, "malformed", False) + return CategoryResult( + self._parse_vnstat_payload(payload, expected_period=expected_period), + "available", + True, + ) @_log_errors async def get_vnstat_metrics(self, period: str) -> dict[str, Any]: @@ -119,11 +135,15 @@ async def get_vnstat_result(self) -> CategoryResult[MutableMapping[str, Any]]: return CategoryResult(empty, result.state, result.authoritative) hourly_raw = result.data if not isinstance(hourly_raw, MutableMapping): - return CategoryResult(empty, "malformed", True) + return CategoryResult(empty, "malformed", False) + if not isinstance(hourly_raw.get("response", ""), str): + return CategoryResult(empty, "malformed", False) opnsense_tz = await self._get_opnsense_timezone() hourly = self._parse_vnstat_payload(hourly_raw, expected_period="hourly") - daily = await self._fetch_vnstat_for(VNSTAT_DAILY_ENDPOINT, "daily") - monthly = await self._fetch_vnstat_for(VNSTAT_MONTHLY_ENDPOINT, "monthly") + daily_result = await self._fetch_vnstat_for_result(VNSTAT_DAILY_ENDPOINT, "daily") + monthly_result = await self._fetch_vnstat_for_result(VNSTAT_MONTHLY_ENDPOINT, "monthly") + daily = daily_result.data + monthly = monthly_result.data interface_names = self._collect_vnstat_interfaces(hourly, daily, monthly) interface_data: dict[str, Any] = {} @@ -161,11 +181,15 @@ async def get_vnstat_result(self) -> CategoryResult[MutableMapping[str, Any]]: "monthly": rows_monthly, "metrics": metrics, } - return CategoryResult( - {"interfaces": interface_data, "interface_count": len(interface_names)}, - "available", - True, - ) + data = {"interfaces": interface_data, "interface_count": len(interface_names)} + non_available = [ + item.state for item in (daily_result, monthly_result) if item.state != "available" + ] + if non_available: + for state in ("pending", "transient", "malformed", "missing"): + if state in non_available: + return CategoryResult(data, state, False) + return CategoryResult(data, "available", True) def _parse_vnstat_payload( self, payload: MutableMapping[str, Any], expected_period: str diff --git a/tests/test_category_result.py b/tests/test_category_result.py index 1cf7fdb..b5a1485 100644 --- a/tests/test_category_result.py +++ b/tests/test_category_result.py @@ -1,10 +1,12 @@ """Tests for authoritative optional-category result contracts.""" from dataclasses import FrozenInstanceError +from datetime import UTC +from unittest.mock import AsyncMock import pytest -from aiopnsense import CategoryResult, CategoryState +from aiopnsense import CategoryResult, CategoryState, OPNsenseClient def test_category_result_is_exported_immutable_and_generic() -> None: @@ -20,14 +22,26 @@ def test_category_result_is_exported_immutable_and_generic() -> None: result.state = "missing" # type: ignore[misc] +@pytest.mark.parametrize( + ("state", "authoritative"), + [("available", False), ("missing", True), ("malformed", True)], +) +def test_category_result_rejects_contradictory_authority( + state: CategoryState, authoritative: bool +) -> None: + """Authority is true exactly for available category data.""" + with pytest.raises(ValueError, match="authoritative"): + CategoryResult({}, state, authoritative) + + @pytest.mark.parametrize( ("states", "expected"), [ (["available", "missing"], ("available", True)), - (["missing", "missing"], ("missing", True)), + (["missing", "missing"], ("missing", False)), (["available", "pending"], ("pending", False)), (["available", "transient"], ("transient", False)), - (["available", "malformed"], ("malformed", True)), + (["available", "malformed"], ("malformed", False)), ], ) def test_dhcp_source_authority_ignores_only_confirmed_inapplicable_sources( @@ -37,3 +51,190 @@ def test_dhcp_source_authority_ignores_only_confirmed_inapplicable_sources( from aiopnsense.dhcp import DHCPMixin assert DHCPMixin._aggregate_dhcp_source_states(states) == expected + + +@pytest.mark.asyncio +async def test_smart_result_preserves_valid_rows_but_marks_mixed_schema_malformed( + make_client, +) -> None: + """SMART invalid rows make partial device data non-authoritative.""" + client = make_client() + try: + client._check_optional_post_endpoint = AsyncMock( + return_value=CategoryResult( + {"devices": [{"ident": "ada0"}, {"ident": ""}, "bad"]}, + "available", + True, + ) + ) + result = await client.get_smart_result() + assert result == CategoryResult([{"ident": "ada0"}], "malformed", False) + + client._check_optional_post_endpoint.return_value = CategoryResult( + {"devices": []}, "available", True + ) + assert await client.get_smart_result() == CategoryResult([], "available", True) + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_unbound_result_distinguishes_mixed_invalid_rows_from_empty(make_client) -> None: + """Unbound retains valid rows while invalid rows make the result malformed.""" + client = make_client() + try: + client.get_host_firmware_version = AsyncMock(return_value="25.7.8") + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult( + {"rows": [{"uuid": "valid"}, {"uuid": ""}, 1]}, "available", True + ) + ) + result = await client.get_unbound_blocklist_result() + assert result == CategoryResult({"valid": {"uuid": "valid"}}, "malformed", False) + + client._check_optional_get_endpoint.return_value = CategoryResult( + {"rows": []}, "available", True + ) + assert await client.get_unbound_blocklist_result() == CategoryResult({}, "available", True) + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_vnstat_result_distinguishes_non_string_response_from_valid_empty( + make_client, +) -> None: + """vnStat requires textual output but accepts an empty text report.""" + client = make_client() + try: + client._get_opnsense_timezone = AsyncMock(return_value=UTC) + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult({"response": 1}, "available", True) + ) + result = await client.get_vnstat_result() + assert result.state == "malformed" + assert result.authoritative is False + + client._check_optional_get_endpoint.return_value = CategoryResult( + {"response": ""}, "available", True + ) + assert await client.get_vnstat_result() == CategoryResult( + {"interfaces": {}, "interface_count": 0}, "available", True + ) + finally: + await client.async_close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "provider", + ["kea", "dnsmasq", "isc_v4", "isc_v6"], +) +async def test_dhcp_provider_invalid_rows_are_schema_malformed(make_client, provider: str) -> None: + """Every applicable DHCP provider classifies a non-list rows field as malformed.""" + client: OPNsenseClient = make_client() + try: + client._dhcp_source_states = [] + if provider == "kea": + client._is_get_endpoint_available = AsyncMock(return_value=True) + client._safe_dict_get = AsyncMock(return_value={"rows": "bad"}) + await client._get_kea_dhcp_leases("/api/kea/leases4/search", "Kea") + elif provider == "dnsmasq": + client._is_get_endpoint_available = AsyncMock(return_value=True) + client._safe_dict_get = AsyncMock(return_value={"rows": "bad"}) + await client._get_dnsmasq_leases() + else: + client._get_endpoint_path = AsyncMock(return_value=f"/{provider}") + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult({"rows": "bad"}, "available", True) + ) + method = ( + client._get_isc_dhcpv4_leases + if provider == "isc_v4" + else client._get_isc_dhcpv6_leases + ) + await method(opnsense_tz=UTC) + assert client._dhcp_source_states == ["malformed"] + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_dhcp_result_keeps_healthy_source_data_when_another_source_is_malformed( + make_client, +) -> None: + """Mixed provider validity retains healthy leases but is non-authoritative.""" + client = make_client() + + def source(state: CategoryState, leases: list[dict]): + """Build a synthetic provider that records state and returns normalized rows.""" + + async def provider(**_kwargs) -> list[dict]: + """Record the provider state for this collection pass.""" + client._record_dhcp_source_state(state) + return leases + + return provider + + try: + client._get_opnsense_timezone = AsyncMock(return_value=UTC) + valid = [{"address": "192.0.2.1", "if_name": "lan", "if_descr": "LAN"}] + client._get_kea_dhcpv4_leases = AsyncMock(side_effect=source("available", valid)) + client._get_kea_dhcpv6_leases = AsyncMock(side_effect=source("malformed", [])) + for name in ( + "_get_isc_dhcpv4_leases", + "_get_isc_dhcpv6_leases", + "_get_dnsmasq_leases", + ): + setattr( + client, + name, + AsyncMock(side_effect=source("missing", [])), + ) + client._get_kea_interfaces = AsyncMock(return_value={}) + + result = await client.get_dhcp_leases_result() + assert result.state == "malformed" + assert result.authoritative is False + assert result.data["leases"]["lan"][0]["address"] == "192.0.2.1" + finally: + await client.async_close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("state", ["pending", "missing", "transient", "malformed"]) +async def test_arp_result_propagates_non_available_endpoint_states( + make_client, state: CategoryState +) -> None: + """ARP fallback empties retain their non-authoritative endpoint state.""" + client = make_client() + try: + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult({}, state, False) + ) + result = await client.get_arp_table_result() + assert result == CategoryResult([], state, False) + assert await client.get_arp_table() == [] + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_arp_result_distinguishes_available_empty_and_malformed_partial_rows( + make_client, +) -> None: + """ARP empty success is authoritative while mixed invalid rows retain usable data.""" + client = make_client() + try: + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult({"rows": []}, "available", True) + ) + assert await client.get_arp_table_result() == CategoryResult([], "available", True) + + valid = {"ip-address": "192.0.2.1"} + client._check_optional_get_endpoint.return_value = CategoryResult( + {"rows": [valid, "bad"]}, "available", True + ) + assert await client.get_arp_table_result() == CategoryResult([valid], "malformed", False) + finally: + await client.async_close() diff --git a/tests/test_dhcp.py b/tests/test_dhcp.py index 70cbaae..14a3d40 100644 --- a/tests/test_dhcp.py +++ b/tests/test_dhcp.py @@ -6,7 +6,7 @@ import pytest -from aiopnsense import OPNsenseClient +from aiopnsense import CategoryResult, OPNsenseClient from tests.conftest import make_mock_session_client ClientType = Callable[..., OPNsenseClient] @@ -164,15 +164,18 @@ async def test_get_arp_table_uses_get_query_param(make_client: ClientType) -> No """ client, _session = make_mock_session_client(make_client) try: - client._safe_dict_get = AsyncMock(return_value={"rows": []}) + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult({"rows": []}, "available", True) + ) await client.get_arp_table(resolve_hostnames=True) - client._safe_dict_get.assert_awaited_with( - "/api/diagnostics/interface/search_arp?resolve=yes" + client._check_optional_get_endpoint.assert_awaited_with( + "/api/diagnostics/interface/search_arp?resolve=yes", + cache_path="/api/diagnostics/interface/search_arp", ) await client.get_arp_table(resolve_hostnames=False) - assert client._safe_dict_get.await_args_list[1].args[0] == ( + assert client._check_optional_get_endpoint.await_args_list[1].args[0] == ( "/api/diagnostics/interface/search_arp?resolve=no" ) finally: @@ -1088,27 +1091,18 @@ async def test_version_switched_get_arp_table_endpoint_unavailable( """ client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(side_effect=[True, False]) - client._safe_dict_get = AsyncMock(return_value={"rows": []}) + client._check_optional_get_endpoint = AsyncMock( + side_effect=[ + CategoryResult({"rows": []}, "available", True), + CategoryResult({}, "missing", False), + ] + ) assert await client.get_arp_table(resolve_hostnames=True) == [] - client._safe_dict_get.assert_awaited_once_with( - "/api/diagnostics/interface/search_arp?resolve=yes" - ) - assert client._is_get_endpoint_available.await_count == 1 - assert ( - client._is_get_endpoint_available.await_args_list[0].args[0] - == "/api/diagnostics/interface/search_arp" - ) + assert client._check_optional_get_endpoint.await_count == 1 - client._safe_dict_get = AsyncMock() assert await client.get_arp_table(resolve_hostnames=False) == [] - client._safe_dict_get.assert_not_awaited() - assert client._is_get_endpoint_available.await_count == 2 - assert ( - client._is_get_endpoint_available.await_args_list[1].args[0] - == "/api/diagnostics/interface/search_arp" - ) + assert client._check_optional_get_endpoint.await_count == 2 finally: await client.async_close() From 6e11c56e8c30d4d63425ae3e875689707e6ed7bf Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 18:32:49 -0400 Subject: [PATCH 06/14] Require explicit optional payload collections --- aiopnsense/_typing.py | 3 +- aiopnsense/client_base.py | 5 +- aiopnsense/dhcp.py | 45 +++++++------ aiopnsense/smart.py | 7 +- aiopnsense/unbound.py | 4 +- aiopnsense/vnstat.py | 8 ++- tests/test_category_result.py | 122 ++++++++++++++++++++++++++++++++-- 7 files changed, 157 insertions(+), 37 deletions(-) diff --git a/aiopnsense/_typing.py b/aiopnsense/_typing.py index 029114e..b2dc733 100644 --- a/aiopnsense/_typing.py +++ b/aiopnsense/_typing.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import AsyncGenerator, MutableMapping +from contextvars import ContextVar from dataclasses import dataclass from datetime import tzinfo from typing import Any, Iterator, Literal, Protocol @@ -64,7 +65,7 @@ class AiopnsenseClientProtocol(Protocol): _endpoint_checked_at: dict[tuple[Literal["get", "post"], str], float] _endpoint_locks: dict[tuple[Literal["get", "post"], str], asyncio.Lock] _optional_endpoint_missing_pending_confirmation: set[tuple[Literal["get", "post"], str]] - _dhcp_source_states: list[CategoryState] + _dhcp_source_states_context: ContextVar[list[CategoryState] | None] async def _get(self, path: str) -> MutableMapping[str, Any] | list | None: ... diff --git a/aiopnsense/client_base.py b/aiopnsense/client_base.py index 67d5a05..656f4d2 100644 --- a/aiopnsense/client_base.py +++ b/aiopnsense/client_base.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import MutableMapping +from contextvars import ContextVar from typing import Any, Literal from urllib.parse import urlparse import warnings @@ -88,7 +89,9 @@ def __init__( self._optional_endpoint_missing_pending_confirmation: set[ tuple[Literal["get", "post"], str] ] = set() - self._dhcp_source_states: list[CategoryState] = [] + self._dhcp_source_states_context: ContextVar[list[CategoryState] | None] = ContextVar( + "dhcp_source_states", default=None + ) positive_ttl = self._opts.get( "endpoint_positive_cache_ttl_seconds", DEFAULT_CACHE_TTL_SECONDS ) diff --git a/aiopnsense/dhcp.py b/aiopnsense/dhcp.py index ef945e6..28a97e7 100644 --- a/aiopnsense/dhcp.py +++ b/aiopnsense/dhcp.py @@ -117,9 +117,9 @@ async def get_arp_table_result( arp_table_info = result.data if not isinstance(arp_table_info, MutableMapping): return CategoryResult([], "malformed", False) - rows = arp_table_info.get("rows", []) - if not isinstance(rows, list): + if "rows" not in arp_table_info or not isinstance(arp_table_info["rows"], list): return CategoryResult([], "malformed", False) + rows = arp_table_info["rows"] arp_table = [dict(row) for row in rows if isinstance(row, MutableMapping)] if len(arp_table) != len(rows): return CategoryResult(arp_table, "malformed", False) @@ -147,14 +147,18 @@ async def get_dhcp_leases_result( self, opnsense_tz: tzinfo | None = None ) -> CategoryResult[dict[str, Any]]: """Return DHCP leases with authority derived from every applicable source.""" - self._dhcp_source_states = [] if opnsense_tz is None: opnsense_tz = await self._get_opnsense_timezone() - leases_raw: list = await self._get_kea_dhcpv4_leases(opnsense_tz=opnsense_tz) - leases_raw += await self._get_kea_dhcpv6_leases(opnsense_tz=opnsense_tz) - leases_raw += await self._get_isc_dhcpv4_leases(opnsense_tz=opnsense_tz) - leases_raw += await self._get_isc_dhcpv6_leases(opnsense_tz=opnsense_tz) - leases_raw += await self._get_dnsmasq_leases(opnsense_tz=opnsense_tz) + state_token = self._dhcp_source_states_context.set([]) + try: + leases_raw: list = await self._get_kea_dhcpv4_leases(opnsense_tz=opnsense_tz) + leases_raw += await self._get_kea_dhcpv6_leases(opnsense_tz=opnsense_tz) + leases_raw += await self._get_isc_dhcpv4_leases(opnsense_tz=opnsense_tz) + leases_raw += await self._get_isc_dhcpv6_leases(opnsense_tz=opnsense_tz) + leases_raw += await self._get_dnsmasq_leases(opnsense_tz=opnsense_tz) + source_states = list(self._dhcp_source_states_context.get() or []) + finally: + self._dhcp_source_states_context.reset(state_token) leases: dict[str, Any] = {} lease_interfaces: dict[str, Any] = await self._get_kea_interfaces() @@ -184,13 +188,14 @@ async def get_dhcp_leases_result( "lease_interfaces": sorted_lease_interfaces, "leases": sorted_leases, } - source_states = self._dhcp_source_states aggregate_state, authoritative = self._aggregate_dhcp_source_states(source_states) return CategoryResult(dhcp_leases, aggregate_state, authoritative) def _record_dhcp_source_state(self, state: CategoryState) -> None: """Record the schema-aware state of one DHCP provider.""" - self._dhcp_source_states.append(state) + source_states = self._dhcp_source_states_context.get() + if source_states is not None: + source_states.append(state) def _unavailable_dhcp_source_state(self, path: str) -> CategoryState: """Resolve a failed source probe as confirmed missing or transient.""" @@ -310,7 +315,7 @@ async def _get_kea_dhcp_leases( _LOGGER.debug("%s not installed", service_name) return [] response = await self._safe_dict_get(lease_endpoint) - if not isinstance(response.get("rows", None), list): + if "rows" not in response or not isinstance(response["rows"], list): self._record_dhcp_source_state("malformed") return [] malformed = False @@ -327,14 +332,14 @@ async def _get_kea_dhcp_leases( res_info = None else: res_resp = await self._safe_dict_get(selected_reservation_endpoint) - if not isinstance(res_resp.get("rows", None), list): + if "rows" not in res_resp or not isinstance(res_resp["rows"], list): malformed = True _LOGGER.debug( "%s reservation lookup returned invalid rows payload", service_name ) res_info = None else: - res_info = res_resp.get("rows", []) + res_info = res_resp["rows"] reservations = {} if res_info is not None: for res in res_info: @@ -343,7 +348,7 @@ async def _get_kea_dhcp_leases( continue if res.get("hw_address", None): reservations.update({res.get("hw_address"): res.get("ip_address", "")}) - leases_info: list = response.get("rows", []) + leases_info: list = response["rows"] leases: list = [] for lease_info in leases_info: if not isinstance(lease_info, MutableMapping): @@ -460,10 +465,10 @@ async def _get_dnsmasq_leases(self, opnsense_tz: tzinfo | None = None) -> list: _LOGGER.debug("Dnsmasq DHCP not installed") return [] response = await self._safe_dict_get(DNSMASQ_LEASES_SEARCH_ENDPOINT) - leases_info: list = response.get("rows", []) - if not isinstance(leases_info, list): + if "rows" not in response or not isinstance(response["rows"], list): self._record_dhcp_source_state("malformed") return [] + leases_info: list = response["rows"] malformed = any(not isinstance(row, MutableMapping) for row in leases_info) cleaned_leases = self._keep_latest_leases(leases_info) @@ -539,10 +544,10 @@ async def _get_isc_dhcpv4_leases(self, opnsense_tz: tzinfo | None = None) -> lis if not isinstance(response, MutableMapping): self._record_dhcp_source_state("malformed") return [] - leases_info: list = response.get("rows", []) - if not isinstance(leases_info, list): + if "rows" not in response or not isinstance(response["rows"], list): self._record_dhcp_source_state("malformed") return [] + leases_info: list = response["rows"] if opnsense_tz is None: opnsense_tz = await self._get_opnsense_timezone() leases: list = [] @@ -613,10 +618,10 @@ async def _get_isc_dhcpv6_leases(self, opnsense_tz: tzinfo | None = None) -> lis if not isinstance(response, MutableMapping): self._record_dhcp_source_state("malformed") return [] - leases_info: list = response.get("rows", []) - if not isinstance(leases_info, list): + if "rows" not in response or not isinstance(response["rows"], list): self._record_dhcp_source_state("malformed") return [] + leases_info: list = response["rows"] if opnsense_tz is None: opnsense_tz = await self._get_opnsense_timezone() leases: list = [] diff --git a/aiopnsense/smart.py b/aiopnsense/smart.py index e706d49..092e2cb 100644 --- a/aiopnsense/smart.py +++ b/aiopnsense/smart.py @@ -37,12 +37,13 @@ async def get_smart_result(self) -> CategoryResult[list[dict[str, Any]]]: smart_info = result.data if not isinstance(smart_info, MutableMapping): return CategoryResult([], "malformed", False) - devices = smart_info.get("devices", []) - if not isinstance(devices, list): + if "devices" not in smart_info or not isinstance(smart_info["devices"], list): _LOGGER.debug( - "Discarding SMART devices payload because devices is not a list: %r", devices + "Discarding SMART devices payload because devices is missing or not a list: %r", + smart_info.get("devices"), ) return CategoryResult([], "malformed", False) + devices = smart_info["devices"] smart_devices: list[dict[str, Any]] = [] malformed = False for device in devices: diff --git a/aiopnsense/unbound.py b/aiopnsense/unbound.py index 62bf242..1dc8c41 100644 --- a/aiopnsense/unbound.py +++ b/aiopnsense/unbound.py @@ -197,9 +197,9 @@ async def get_unbound_blocklist_result(self) -> CategoryResult[dict[str, Any]]: dnsbl_raw = result.data if not isinstance(dnsbl_raw, MutableMapping): return CategoryResult({}, "malformed", False) - dnsbl_rows = dnsbl_raw.get("rows", []) - if not isinstance(dnsbl_rows, list): + if "rows" not in dnsbl_raw or not isinstance(dnsbl_raw["rows"], list): return CategoryResult({}, "malformed", False) + dnsbl_rows = dnsbl_raw["rows"] if not dnsbl_rows: return CategoryResult({}, "available", True) dnsbl_full: dict[str, Any] = {} diff --git a/aiopnsense/vnstat.py b/aiopnsense/vnstat.py index 7455fe3..da98f01 100644 --- a/aiopnsense/vnstat.py +++ b/aiopnsense/vnstat.py @@ -77,8 +77,10 @@ async def _fetch_vnstat_for_result( _LOGGER.debug("vnStat %s endpoint unavailable", expected_period) return CategoryResult(empty, result.state, False) payload = result.data - if not isinstance(payload, MutableMapping) or not isinstance( - payload.get("response", ""), str + if ( + not isinstance(payload, MutableMapping) + or "response" not in payload + or not isinstance(payload["response"], str) ): return CategoryResult(empty, "malformed", False) return CategoryResult( @@ -136,7 +138,7 @@ async def get_vnstat_result(self) -> CategoryResult[MutableMapping[str, Any]]: hourly_raw = result.data if not isinstance(hourly_raw, MutableMapping): return CategoryResult(empty, "malformed", False) - if not isinstance(hourly_raw.get("response", ""), str): + if "response" not in hourly_raw or not isinstance(hourly_raw["response"], str): return CategoryResult(empty, "malformed", False) opnsense_tz = await self._get_opnsense_timezone() hourly = self._parse_vnstat_payload(hourly_raw, expected_period="hourly") diff --git a/tests/test_category_result.py b/tests/test_category_result.py index b5a1485..cf15c30 100644 --- a/tests/test_category_result.py +++ b/tests/test_category_result.py @@ -1,5 +1,6 @@ """Tests for authoritative optional-category result contracts.""" +import asyncio from dataclasses import FrozenInstanceError from datetime import UTC from unittest.mock import AsyncMock @@ -74,6 +75,9 @@ async def test_smart_result_preserves_valid_rows_but_marks_mixed_schema_malforme {"devices": []}, "available", True ) assert await client.get_smart_result() == CategoryResult([], "available", True) + + client._check_optional_post_endpoint.return_value = CategoryResult({}, "available", True) + assert await client.get_smart_result() == CategoryResult([], "malformed", False) finally: await client.async_close() @@ -96,6 +100,9 @@ async def test_unbound_result_distinguishes_mixed_invalid_rows_from_empty(make_c {"rows": []}, "available", True ) assert await client.get_unbound_blocklist_result() == CategoryResult({}, "available", True) + + client._check_optional_get_endpoint.return_value = CategoryResult({}, "available", True) + assert await client.get_unbound_blocklist_result() == CategoryResult({}, "malformed", False) finally: await client.async_close() @@ -115,6 +122,9 @@ async def test_vnstat_result_distinguishes_non_string_response_from_valid_empty( assert result.state == "malformed" assert result.authoritative is False + client._check_optional_get_endpoint.return_value = CategoryResult({}, "available", True) + assert (await client.get_vnstat_result()).state == "malformed" + client._check_optional_get_endpoint.return_value = CategoryResult( {"response": ""}, "available", True ) @@ -130,23 +140,26 @@ async def test_vnstat_result_distinguishes_non_string_response_from_valid_empty( "provider", ["kea", "dnsmasq", "isc_v4", "isc_v6"], ) -async def test_dhcp_provider_invalid_rows_are_schema_malformed(make_client, provider: str) -> None: - """Every applicable DHCP provider classifies a non-list rows field as malformed.""" +@pytest.mark.parametrize("payload", [{}, {"rows": "bad"}]) +async def test_dhcp_provider_invalid_rows_are_schema_malformed( + make_client, provider: str, payload: dict +) -> None: + """Every DHCP provider requires an explicitly present list-valued rows field.""" client: OPNsenseClient = make_client() + state_token = client._dhcp_source_states_context.set([]) try: - client._dhcp_source_states = [] if provider == "kea": client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock(return_value={"rows": "bad"}) + client._safe_dict_get = AsyncMock(return_value=payload) await client._get_kea_dhcp_leases("/api/kea/leases4/search", "Kea") elif provider == "dnsmasq": client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock(return_value={"rows": "bad"}) + client._safe_dict_get = AsyncMock(return_value=payload) await client._get_dnsmasq_leases() else: client._get_endpoint_path = AsyncMock(return_value=f"/{provider}") client._check_optional_get_endpoint = AsyncMock( - return_value=CategoryResult({"rows": "bad"}, "available", True) + return_value=CategoryResult(payload, "available", True) ) method = ( client._get_isc_dhcpv4_leases @@ -154,8 +167,30 @@ async def test_dhcp_provider_invalid_rows_are_schema_malformed(make_client, prov else client._get_isc_dhcpv6_leases ) await method(opnsense_tz=UTC) - assert client._dhcp_source_states == ["malformed"] + assert client._dhcp_source_states_context.get() == ["malformed"] + finally: + client._dhcp_source_states_context.reset(state_token) + await client.async_close() + + +@pytest.mark.asyncio +async def test_kea_reservation_provider_requires_rows_key(make_client) -> None: + """An available Kea reservation response without rows makes the source malformed.""" + client = make_client() + state_token = client._dhcp_source_states_context.set([]) + try: + client._is_get_endpoint_available = AsyncMock(return_value=True) + client._get_endpoint_path = AsyncMock(return_value="/reservation") + client._safe_dict_get = AsyncMock(side_effect=[{"rows": []}, {}]) + await client._get_kea_dhcp_leases( + "/api/kea/leases4/search", + "Kea", + "/reservation", + "/reservationCamel", + ) + assert client._dhcp_source_states_context.get() == ["malformed"] finally: + client._dhcp_source_states_context.reset(state_token) await client.async_close() @@ -231,6 +266,9 @@ async def test_arp_result_distinguishes_available_empty_and_malformed_partial_ro ) assert await client.get_arp_table_result() == CategoryResult([], "available", True) + client._check_optional_get_endpoint.return_value = CategoryResult({}, "available", True) + assert await client.get_arp_table_result() == CategoryResult([], "malformed", False) + valid = {"ip-address": "192.0.2.1"} client._check_optional_get_endpoint.return_value = CategoryResult( {"rows": [valid, "bad"]}, "available", True @@ -238,3 +276,73 @@ async def test_arp_result_distinguishes_available_empty_and_malformed_partial_ro assert await client.get_arp_table_result() == CategoryResult([valid], "malformed", False) finally: await client.async_close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("period", ["hourly", "daily", "monthly", "yearly"]) +async def test_vnstat_period_result_requires_response_key(make_client, period: str) -> None: + """Every vnStat period requires an explicitly present response string.""" + client = make_client() + try: + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult({}, "available", True) + ) + result = await client._fetch_vnstat_for_result(f"/vnstat/{period}", period) + assert result.state == "malformed" + assert result.authoritative is False + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_concurrent_dhcp_results_keep_provider_states_request_local(make_client) -> None: + """Interleaved DHCP collections on one client cannot contaminate authority state.""" + client = make_client() + available_started = asyncio.Event() + malformed_recorded = asyncio.Event() + + async def first_provider(**_kwargs) -> list[dict]: + """Interleave the first provider based on the current task name.""" + task = asyncio.current_task() + if task is not None and task.get_name() == "available-collection": + client._record_dhcp_source_state("available") + available_started.set() + await malformed_recorded.wait() + else: + await available_started.wait() + client._record_dhcp_source_state("malformed") + malformed_recorded.set() + return [] + + async def missing_provider(**_kwargs) -> list[dict]: + """Record a confirmed inapplicable provider for the current request.""" + client._record_dhcp_source_state("missing") + await asyncio.sleep(0) + return [] + + try: + client._get_opnsense_timezone = AsyncMock(return_value=UTC) + client._get_kea_dhcpv4_leases = AsyncMock(side_effect=first_provider) + for name in ( + "_get_kea_dhcpv6_leases", + "_get_isc_dhcpv4_leases", + "_get_isc_dhcpv6_leases", + "_get_dnsmasq_leases", + ): + setattr(client, name, AsyncMock(side_effect=missing_provider)) + client._get_kea_interfaces = AsyncMock(return_value={}) + + available_task = asyncio.create_task( + client.get_dhcp_leases_result(), name="available-collection" + ) + malformed_task = asyncio.create_task( + client.get_dhcp_leases_result(), name="malformed-collection" + ) + available_result, malformed_result = await asyncio.gather(available_task, malformed_task) + + assert available_result.state == "available" + assert available_result.authoritative is True + assert malformed_result.state == "malformed" + assert malformed_result.authoritative is False + finally: + await client.async_close() From 5d68cafa0a3f527fb728c6f7780e448f37d466e3 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 19:16:58 -0400 Subject: [PATCH 07/14] Correct optional endpoint result docstrings --- aiopnsense/client_endpoint.py | 8 +++----- aiopnsense/client_transport.py | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/aiopnsense/client_endpoint.py b/aiopnsense/client_endpoint.py index 8fafe33..c81f334 100644 --- a/aiopnsense/client_endpoint.py +++ b/aiopnsense/client_endpoint.py @@ -494,11 +494,9 @@ async def _check_optional_endpoint( force_refresh (bool): Whether to bypass stale/confirmed cache state. Returns: - CategoryResult: ``("available", payload)`` or ``("malformed", payload)`` - when the request is reachable and can be interpreted as a response, - ``("missing", {})`` only after a confirmed 404 path, and - ``("unavailable", {})`` for transient failures or unregistered - optional endpoints. + CategoryResult[object]: Probe result with an ``"available"``, + ``"malformed"``, ``"missing"``, ``"pending"``, or ``"transient"`` + state and its associated payload. Notes: The method only short-circuits via a fresh confirmed-negative cache diff --git a/aiopnsense/client_transport.py b/aiopnsense/client_transport.py index 67b2e12..f7b8f63 100644 --- a/aiopnsense/client_transport.py +++ b/aiopnsense/client_transport.py @@ -330,8 +330,8 @@ async def _do_optional_get(self, path: str, caller: str = "Unknown") -> Category caller (str): Caller name used for diagnostics and logging. Returns: - tuple[Literal["available", "malformed", "missing", "unavailable"], object]: - Availability state and parsed response payload. + CategoryResult[object]: Result with an ``"available"``, ``"malformed"``, + ``"missing"``, or ``"transient"`` state and its associated payload. """ self._rest_api_query_count += 1 url: str = f"{self._url}{path}" From 26a0f23e5860c697b0de7898112c308fb147e0e5 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 19:28:18 -0400 Subject: [PATCH 08/14] Raise endpoint reconciliation diff coverage --- aiopnsense/dhcp.py | 3 -- tests/test_category_result.py | 11 +++++ tests/test_client_endpoint.py | 78 +++++++++++++++++++++++++++++++++ tests/test_client_queue.py | 32 ++++++++++++++ tests/test_client_transport.py | 34 +++++++++++++++ tests/test_dhcp.py | 79 ++++++++++++++++++++++++++++++++-- tests/test_smart.py | 25 +++++++++++ tests/test_speedtest.py | 23 ++++++++++ tests/test_unbound.py | 28 ++++++++++++ tests/test_vnstat.py | 72 +++++++++++++++++++++++++++++++ 10 files changed, 379 insertions(+), 6 deletions(-) diff --git a/aiopnsense/dhcp.py b/aiopnsense/dhcp.py index 28a97e7..82091dc 100644 --- a/aiopnsense/dhcp.py +++ b/aiopnsense/dhcp.py @@ -474,9 +474,6 @@ async def _get_dnsmasq_leases(self, opnsense_tz: tzinfo | None = None) -> list: leases: list = [] for lease_info in cleaned_leases: - if not isinstance(lease_info, MutableMapping): - malformed = True - continue lease: dict[str, Any] = {} lease["address"] = lease_info.get("address", None) lease["hostname"] = ( diff --git a/tests/test_category_result.py b/tests/test_category_result.py index cf15c30..12b8f86 100644 --- a/tests/test_category_result.py +++ b/tests/test_category_result.py @@ -35,6 +35,17 @@ def test_category_result_rejects_contradictory_authority( CategoryResult({}, state, authoritative) +@pytest.mark.parametrize("value", [None, ("unknown", {"value": 1}), ("available",)]) +def test_category_result_coerce_rejects_invalid_legacy_values(value: object) -> None: + """Invalid legacy values become a non-authoritative malformed result.""" + assert CategoryResult.coerce(value) == CategoryResult({}, "malformed", False) + + +def test_category_result_comparison_with_unrelated_value_is_false() -> None: + """An unrelated value does not compare equal to a category result.""" + assert CategoryResult({}, "available", True) != object() + + @pytest.mark.parametrize( ("states", "expected"), [ diff --git a/tests/test_client_endpoint.py b/tests/test_client_endpoint.py index 27263a8..835555f 100644 --- a/tests/test_client_endpoint.py +++ b/tests/test_client_endpoint.py @@ -901,6 +901,84 @@ def get(url: str, **_kwargs: Any) -> FakeResponse: await client.async_close() +@pytest.mark.asyncio +@pytest.mark.parametrize( + "response_or_error", + [FakeResponse(status=503, reason="Unavailable", ok=False), TimeoutError("timed out")], +) +async def test_optional_endpoint_core_health_rejects_failed_requests( + response_or_error: FakeResponse | Exception, + make_client: MakeClientFactory, +) -> None: + """A non-OK or failed firmware request cannot confirm optional absence.""" + client, session = make_mock_session_client(make_client) + + def get(*_args: object, **_kwargs: object) -> FakeResponse: + """Return or raise the configured firmware health outcome.""" + if isinstance(response_or_error, Exception): + raise response_or_error + return response_or_error + + session.get = get + try: + assert await client._is_core_firmware_endpoint_healthy() is False + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_check_optional_endpoint_rejects_unregistered_route( + make_client: MakeClientFactory, +) -> None: + """An unregistered route is transient and never reaches transport.""" + client, _session = make_mock_session_client(make_client) + client._get_optional = AsyncMock() + try: + assert await client._check_optional_get_endpoint("/api/unregistered") == ( + "transient", + {}, + ) + client._get_optional.assert_not_awaited() + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_concurrent_endpoint_probes_share_fresh_locked_result( + make_client: MakeClientFactory, +) -> None: + """A waiter reuses the fresh result stored by the probe holding the lock.""" + client, session = make_mock_session_client(make_client) + path = "/api/test/endpoint" + calls = 0 + + class DelayedResponse(FakeResponse): + """Successful response that lets a competing probe reach the lock.""" + + async def __aenter__(self) -> FakeResponse: + """Yield once before exposing the successful response.""" + await asyncio.sleep(0) + return await super().__aenter__() + + def get(*_args: object, **_kwargs: object) -> FakeResponse: + """Count endpoint requests and yield once while entering the response.""" + nonlocal calls + calls += 1 + return DelayedResponse(status=200, ok=True) + + session.get = get + try: + first_result, second_result = await asyncio.gather( + client._is_get_endpoint_available(path), + client._is_get_endpoint_available(path), + ) + assert first_result is True + assert second_result is True + assert calls == 1 + finally: + await client.async_close() + + @pytest.mark.asyncio async def test_optional_endpoint_calls_are_serialized_per_cache_key( make_client: MakeClientFactory, diff --git a/tests/test_client_queue.py b/tests/test_client_queue.py index bbf2664..dc74ca9 100644 --- a/tests/test_client_queue.py +++ b/tests/test_client_queue.py @@ -9,6 +9,7 @@ import pytest from aiopnsense import ( + CategoryResult, OPNsenseError, client_queue as aiopnsense_client_queue, ) @@ -19,6 +20,37 @@ ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "path", "payload", "queue_method"), + [ + ("_get_optional", "/api/optional/get", None, "optional_get"), + ("_post_optional", "/api/optional/post", {"read": True}, "optional_post"), + ], +) +async def test_optional_queue_wrappers_return_category_results( + method_name: str, + path: str, + payload: MutableMapping[str, Any] | None, + queue_method: str, + make_client: MakeClientFactory, +) -> None: + """Optional wrappers enqueue the matching operation and return its envelope.""" + client, _session = make_mock_session_client(make_client) + expected = CategoryResult({"value": 1}, "available", True) + client._queue_request = AsyncMock(return_value=expected) + try: + if payload is None: + result = await getattr(client, method_name)(path) + client._queue_request.assert_awaited_once_with(queue_method, path) + else: + result = await getattr(client, method_name)(path, payload) + client._queue_request.assert_awaited_once_with(queue_method, path, payload) + assert result is expected + finally: + await client.async_close() + + @pytest.mark.asyncio async def test_opnsenseclient_async_close(make_client: MakeClientFactory) -> None: """Verify ``async_close`` cancels worker tasks and clears queued requests. diff --git a/tests/test_client_transport.py b/tests/test_client_transport.py index 5f380af..45a6da4 100644 --- a/tests/test_client_transport.py +++ b/tests/test_client_transport.py @@ -294,6 +294,40 @@ async def test_do_optional_post_returns_tri_state_envelope( await client.async_close() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "session_method", "args"), + [ + ("_do_optional_get", "get", ("/api/optional",)), + ("_do_optional_post", "post", ("/api/optional", {"read": True})), + ], +) +async def test_optional_transport_raises_for_non_ok_response_in_throw_mode( + method_name: str, + session_method: str, + args: tuple[object, ...], + make_client: MakeClientFactory, +) -> None: + """Optional transports preserve configured HTTP exception behavior.""" + client, session = make_mock_session_client(make_client) + setattr( + session, + session_method, + lambda *_args, **_kwargs: FakeResponse( + status=403, + reason="Forbidden", + ok=False, + include_request_info=True, + ), + ) + client._throw_errors = True + try: + with pytest.raises(OPNsensePrivilegeMissing): + await getattr(client, method_name)(*args, caller="test") + finally: + await client.async_close() + + @pytest.mark.asyncio @pytest.mark.parametrize("method", ["get", "post"]) async def test_optional_transport_classifies_malformed_success( diff --git a/tests/test_dhcp.py b/tests/test_dhcp.py index 14a3d40..842a801 100644 --- a/tests/test_dhcp.py +++ b/tests/test_dhcp.py @@ -818,6 +818,17 @@ async def test_get_dnsmasq_leases_invalid_rows_and_expiry_paths(make_client: Cli "hwaddr": "cc:cc:cc", "expire": "never", }, + { + "address": None, + "hostname": "missing-address", + "if": "em0", + "expire": "never", + }, + { + "address": "192.0.2.25", + "hostname": "missing-interface", + "expire": "never", + }, ] } ) @@ -839,6 +850,7 @@ async def test_get_dnsmasq_leases_invalid_rows_and_expiry_paths(make_client: Cli assert lease_by_address["192.0.2.23"]["reserved_by"] == ["hwaddr"] assert lease_by_address["192.0.2.23"]["client_id"] == "01:33:44" assert lease_by_address["192.0.2.24"]["type"] == "dynamic" + assert "192.0.2.25" not in lease_by_address finally: await client.async_close() @@ -865,14 +877,34 @@ async def test_get_isc_dhcpv4_and_v6_cover_invalid_and_expired_paths( client._check_optional_get_endpoint = AsyncMock( side_effect=[ + ("available", "bad-response"), ("available", {"rows": "bad"}), ( "available", { "rows": [ + None, {"state": "inactive", "mac": "skip"}, - {"state": "active", "mac": "bad-time", "ends": "invalid-date"}, - {"state": "active", "mac": "expired", "ends": past_str}, + { + "state": "active", + "mac": "bad-identity", + "address": None, + "if": "em0", + }, + { + "state": "active", + "mac": "bad-time", + "address": "10.0.0.7", + "if": "em0", + "ends": "invalid-date", + }, + { + "state": "active", + "mac": "expired", + "address": "10.0.0.8", + "if": "em0", + "ends": past_str, + }, { "state": "active", "mac": "ok", @@ -885,6 +917,7 @@ async def test_get_isc_dhcpv4_and_v6_cover_invalid_and_expired_paths( ] ) assert await client._get_isc_dhcpv4_leases() == [] + assert await client._get_isc_dhcpv4_leases() == [] v4_leases = await client._get_isc_dhcpv4_leases() assert len(v4_leases) == 1 assert v4_leases[0]["mac"] == "ok" @@ -892,18 +925,34 @@ async def test_get_isc_dhcpv4_and_v6_cover_invalid_and_expired_paths( client._check_optional_get_endpoint = AsyncMock( side_effect=[ + ("available", "bad-response"), ("available", {"rows": "bad"}), ( "available", { "rows": [ None, + {"state": "inactive", "mac": "skip-v6"}, + {"state": "active", "mac": None}, + { + "state": "active", + "mac": "bad-identity-v6", + "address": "2001:db8::7", + }, { "state": "active", "mac": "bad-time-v6", + "address": "2001:db8::8", + "if": "em1", "ends": "invalid-date", }, - {"state": "active", "mac": "expired-v6", "ends": past_str}, + { + "state": "active", + "mac": "expired-v6", + "address": "2001:db8::9", + "if": "em1", + "ends": past_str, + }, { "state": "active", "mac": "ok-v6", @@ -916,6 +965,7 @@ async def test_get_isc_dhcpv4_and_v6_cover_invalid_and_expired_paths( ] ) assert await client._get_isc_dhcpv6_leases() == [] + assert await client._get_isc_dhcpv6_leases() == [] v6_leases = await client._get_isc_dhcpv6_leases() assert len(v6_leases) == 1 assert v6_leases[0]["mac"] == "ok-v6" @@ -1107,6 +1157,29 @@ async def test_version_switched_get_arp_table_endpoint_unavailable( await client.async_close() +@pytest.mark.asyncio +async def test_get_arp_table_result_rejects_non_mapping_data( + make_client: ClientType, +) -> None: + """Verify available ARP responses with non-mapping data are malformed. + + Args: + make_client (ClientType): Fixture factory returning ``OPNsenseClient`` instances. + + Returns: + None: This test validates schema-aware ARP response handling. + """ + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult("bad-response", "available", True) + ) + + assert await client.get_arp_table_result() == CategoryResult([], "malformed", False) + finally: + await client.async_close() + + @pytest.mark.asyncio async def test_version_switched_get_kea_interfaces_endpoint_unavailable( make_client: ClientType, diff --git a/tests/test_smart.py b/tests/test_smart.py index 7c673c4..6a4e74e 100644 --- a/tests/test_smart.py +++ b/tests/test_smart.py @@ -151,6 +151,31 @@ async def test_get_smart_returns_empty_list_for_non_list_payload(make_client: Cl await client.async_close() +@pytest.mark.asyncio +async def test_get_smart_result_marks_non_mapping_payload_malformed( + make_client: ClientType, +) -> None: + """SMART result queries should reject non-mapping available payloads. + + Args: + make_client (ClientType): Fixture factory returning ``OPNsenseClient`` instances. + + Returns: + None: This test validates the SMART result availability envelope. + """ + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_post_endpoint = AsyncMock(return_value=("available", [])) + + result = await client.get_smart_result() + + assert result.data == [] + assert result.state == "malformed" + assert result.authoritative is False + finally: + await client.async_close() + + @pytest.mark.asyncio @pytest.mark.parametrize( ("operation", "endpoint", "expected"), diff --git a/tests/test_speedtest.py b/tests/test_speedtest.py index 566bc2e..852171d 100644 --- a/tests/test_speedtest.py +++ b/tests/test_speedtest.py @@ -149,6 +149,29 @@ async def test_get_speedtest_normalizes_latest_and_stat_payloads(make_client) -> False, id="showstat-missing", ), + pytest.param( + [ + ( + "available", + [ + [ + "2026-03-14T03:09:45", + "198.51.100.10", + "72800", + "Test ISP, New York, NY", + "United States", + "1", + "2", + "3", + "https://www.speedtest.net/result/c/abc", + ] + ], + ), + ("transient", {}), + ], + False, + id="showstat-transient", + ), ], ) @pytest.mark.asyncio diff --git a/tests/test_unbound.py b/tests/test_unbound.py index 99601ea..f7d2bab 100644 --- a/tests/test_unbound.py +++ b/tests/test_unbound.py @@ -76,6 +76,34 @@ async def test_get_unbound_blocklist_returns_uuid_mapping(make_client) -> None: await client.async_close() +@pytest.mark.asyncio +async def test_get_unbound_blocklist_result_marks_valid_rows_authoritative( + make_client: ClientType, +) -> None: + """A fully valid DNSBL response should produce an authoritative result. + + Args: + make_client (ClientType): Fixture factory returning ``OPNsenseClient`` instances. + + Returns: + None: This test validates the DNSBL result availability envelope. + """ + client, _session = make_mock_session_client(make_client) + try: + client.get_host_firmware_version = AsyncMock(return_value="25.7.8") + client._check_optional_get_endpoint = AsyncMock( + return_value=("available", {"rows": [{"uuid": "dnsbl1", "enabled": "1"}]}) + ) + + result = await client.get_unbound_blocklist_result() + + assert result.data == {"dnsbl1": {"uuid": "dnsbl1", "enabled": "1"}} + assert result.state == "available" + assert result.authoritative is True + finally: + await client.async_close() + + @pytest.mark.asyncio @pytest.mark.parametrize("api_response", [{}, {"rows": []}, {"rows": "not-a-list"}, []]) async def test_get_unbound_blocklist_handles_empty_or_invalid_responses( diff --git a/tests/test_vnstat.py b/tests/test_vnstat.py index eeacc33..e968a41 100644 --- a/tests/test_vnstat.py +++ b/tests/test_vnstat.py @@ -284,6 +284,78 @@ async def test_get_vnstat_fallback_for_optional_states( await client.async_close() +@pytest.mark.asyncio +async def test_get_vnstat_result_marks_non_mapping_hourly_payload_malformed( + make_client: Any, +) -> None: + """An available non-mapping hourly payload should be non-authoritative. + + Args: + make_client (Any): Fixture factory returning OPNsense clients. + + Returns: + None: This test validates the vnStat result availability envelope. + """ + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock(return_value=("available", [])) + + result = await client.get_vnstat_result() + + assert result.data == {"interfaces": {}, "interface_count": 0} + assert result.state == "malformed" + assert result.authoritative is False + finally: + await client.async_close() + + +@pytest.mark.parametrize( + ("daily_state", "monthly_state", "expected_state"), + [ + ("pending", "malformed", "pending"), + ("transient", "pending", "pending"), + ("missing", "malformed", "malformed"), + ("missing", "available", "missing"), + ], +) +@pytest.mark.asyncio +async def test_get_vnstat_result_uses_subordinate_state_precedence( + make_client: Any, + daily_state: str, + monthly_state: str, + expected_state: str, +) -> None: + """Daily and monthly failures should use the documented state precedence. + + Args: + make_client (Any): Fixture factory returning OPNsense clients. + daily_state (str): Optional endpoint state for daily data. + monthly_state (str): Optional endpoint state for monthly data. + expected_state (str): Expected aggregate result state. + + Returns: + None: This test validates subordinate vnStat availability precedence. + """ + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock( + side_effect=[ + ("available", {"response": ""}), + (daily_state, {}), + (monthly_state, {"response": ""} if monthly_state == "available" else {}), + ] + ) + client._get_opnsense_timezone = AsyncMock(return_value=UTC) + + result = await client.get_vnstat_result() + + assert result.data == {"interfaces": {}, "interface_count": 0} + assert result.state == expected_state + assert result.authoritative is False + finally: + await client.async_close() + + @pytest.mark.asyncio async def test_parse_vnstat_payload_and_helpers_edge_cases(make_client) -> None: """VnStat payload/helper methods should handle malformed and fallback scenarios.""" From 1b5a2b9e4bf55c50ad1ccc5c52b7b62d09c43a5c Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 19:39:39 -0400 Subject: [PATCH 09/14] Simplify endpoint reconciliation internals --- aiopnsense/client_endpoint.py | 24 +++---- aiopnsense/client_transport.py | 123 ++++++++++++++------------------- aiopnsense/dhcp.py | 6 +- 3 files changed, 63 insertions(+), 90 deletions(-) diff --git a/aiopnsense/client_endpoint.py b/aiopnsense/client_endpoint.py index c81f334..a927a25 100644 --- a/aiopnsense/client_endpoint.py +++ b/aiopnsense/client_endpoint.py @@ -321,26 +321,18 @@ async def _is_endpoint_available( return False cache_key = self._get_endpoint_cache_key(normalized_method, path) - cached_state = self._endpoint_availability.get(cache_key) - cached_at = self._endpoint_checked_at.get(cache_key) - if ( - not force_refresh - and cached_state is not None - and cached_at is not None - and self._is_endpoint_cache_fresh(normalized_method, path, cached_state, cached_at) - ): + cached_state = self._get_cached_endpoint_availability( + normalized_method, path, force_refresh + ) + if cached_state is not None: return cached_state == "available" cache_lock = self._endpoint_locks.setdefault(cache_key, asyncio.Lock()) async with cache_lock: - cached_state = self._endpoint_availability.get(cache_key) - cached_at = self._endpoint_checked_at.get(cache_key) - if ( - not force_refresh - and cached_state is not None - and cached_at is not None - and self._is_endpoint_cache_fresh(normalized_method, path, cached_state, cached_at) - ): + cached_state = self._get_cached_endpoint_availability( + normalized_method, path, force_refresh + ) + if cached_state is not None: return cached_state == "available" self._rest_api_query_count += 1 diff --git a/aiopnsense/client_transport.py b/aiopnsense/client_transport.py index f7b8f63..f4908bd 100644 --- a/aiopnsense/client_transport.py +++ b/aiopnsense/client_transport.py @@ -333,62 +333,7 @@ async def _do_optional_get(self, path: str, caller: str = "Unknown") -> Category CategoryResult[object]: Result with an ``"available"``, ``"malformed"``, ``"missing"``, or ``"transient"`` state and its associated payload. """ - self._rest_api_query_count += 1 - url: str = f"{self._url}{path}" - _LOGGER.debug("[optional_get] url: %s", url) - try: - async with self._session.get( - url, - auth=aiohttp.BasicAuth(self._username, self._password), - timeout=aiohttp.ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT_SECONDS), - ssl=self._verify_ssl, - ) as response: - _LOGGER.debug("[optional_get] Response %s: %s", response.status, response.reason) - if response.ok: - try: - return CategoryResult( - await response.json(content_type=None), "available", True - ) - except (ValueError, UnicodeDecodeError) as err: - _LOGGER.debug( - "Optional GET endpoint returned malformed JSON for %s: %s", - path, - err, - ) - return CategoryResult({}, "malformed", False) - if response.status == 404: - _LOGGER.debug( - "Optional GET endpoint unavailable (HTTP 404). Path: %s (called by %s)", - path, - caller, - ) - return CategoryResult({}, "missing", False) - if response.status == 403: - _LOGGER.error( - "Permission Error in optional_get (called by %s). Path: %s. Ensure the OPNsense user connected to HA has appropriate access. Recommend full admin access", - caller, - url, - ) - else: - _LOGGER.warning( - "Transient optional GET endpoint failure for %s. Response %s: %s", - path, - response.status, - response.reason, - ) - if self._throw_errors: - raise _opnsense_http_error(response.status, response.reason) - except (aiohttp.ClientError, TimeoutError) as e: - _LOGGER.warning( - "Optional GET endpoint availability check failed for %s. %s: %s.", - path, - type(e).__name__, - e, - ) - if self._throw_errors: - raise _map_opnsense_exception(e) from e - - return CategoryResult({}, "transient", False) + return await self._do_optional_request(path, caller, "get") async def _do_optional_post( self, @@ -403,21 +348,54 @@ async def _do_optional_post( payload: Optional JSON request payload. caller: Caller name used for diagnostics and logging. + Returns: + Availability state and decoded response payload. + """ + return await self._do_optional_request(path, caller, "post", payload) + + async def _do_optional_request( + self, + path: str, + caller: str, + method: Literal["get", "post"], + payload: MutableMapping[str, Any] | None = None, + ) -> CategoryResult[object]: + """Execute an optional request and classify endpoint availability. + + Args: + path: API endpoint path to request. + caller: Caller name used for diagnostics and logging. + method: HTTP request method to use. + payload: Optional JSON request payload used only for POST requests. + Returns: Availability state and decoded response payload. """ self._rest_api_query_count += 1 url = f"{self._url}{path}" - _LOGGER.debug("[optional_post] url: %s", url) + operation = f"optional_{method}" + method_label = method.upper() + _LOGGER.debug("[%s] url: %s", operation, url) + auth = aiohttp.BasicAuth(self._username, self._password) + timeout = aiohttp.ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT_SECONDS) try: - async with self._session.post( - url, - auth=aiohttp.BasicAuth(self._username, self._password), - json=payload, - timeout=aiohttp.ClientTimeout(total=DEFAULT_REQUEST_TIMEOUT_SECONDS), - ssl=self._verify_ssl, - ) as response: - _LOGGER.debug("[optional_post] Response %s: %s", response.status, response.reason) + if method == "get": + request = self._session.get( + url, + auth=auth, + timeout=timeout, + ssl=self._verify_ssl, + ) + else: + request = self._session.post( + url, + auth=auth, + json=payload, + timeout=timeout, + ssl=self._verify_ssl, + ) + async with request as response: + _LOGGER.debug("[%s] Response %s: %s", operation, response.status, response.reason) if response.ok: try: return CategoryResult( @@ -425,27 +403,31 @@ async def _do_optional_post( ) except (ValueError, UnicodeDecodeError) as err: _LOGGER.debug( - "Optional POST endpoint returned malformed JSON for %s: %s", + "Optional %s endpoint returned malformed JSON for %s: %s", + method_label, path, err, ) return CategoryResult({}, "malformed", False) if response.status == 404: _LOGGER.debug( - "Optional POST endpoint unavailable (HTTP 404). Path: %s (called by %s)", + "Optional %s endpoint unavailable (HTTP 404). Path: %s (called by %s)", + method_label, path, caller, ) return CategoryResult({}, "missing", False) if response.status == 403: _LOGGER.error( - "Permission Error in optional_post (called by %s). Path: %s. Ensure the OPNsense user connected to HA has appropriate access. Recommend full admin access", + "Permission Error in %s (called by %s). Path: %s. Ensure the OPNsense user connected to HA has appropriate access. Recommend full admin access", + operation, caller, url, ) else: _LOGGER.warning( - "Transient optional POST endpoint failure for %s. Response %s: %s", + "Transient optional %s endpoint failure for %s. Response %s: %s", + method_label, path, response.status, response.reason, @@ -454,7 +436,8 @@ async def _do_optional_post( raise _opnsense_http_error(response.status, response.reason) except (aiohttp.ClientError, TimeoutError) as err: _LOGGER.warning( - "Optional POST endpoint availability check failed for %s. %s: %s.", + "Optional %s endpoint availability check failed for %s. %s: %s.", + method_label, path, type(err).__name__, err, diff --git a/aiopnsense/dhcp.py b/aiopnsense/dhcp.py index 82091dc..9e91977 100644 --- a/aiopnsense/dhcp.py +++ b/aiopnsense/dhcp.py @@ -354,10 +354,8 @@ async def _get_kea_dhcp_leases( if not isinstance(lease_info, MutableMapping): malformed = True continue - if ( - lease_info is None - or not api_value_matches(lease_info.get("state"), "0") - or (require_hardware_address and not lease_info.get("hwaddr", None)) + if not api_value_matches(lease_info.get("state"), "0") or ( + require_hardware_address and not lease_info.get("hwaddr", None) ): continue if not isinstance(lease_info.get("address"), str) or not isinstance( From a8bd1027cd2d1d0330b98679803cfd4a0709d5bc Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 19:44:13 -0400 Subject: [PATCH 10/14] Inline vnStat result unwrapping --- aiopnsense/vnstat.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/aiopnsense/vnstat.py b/aiopnsense/vnstat.py index da98f01..013d006 100644 --- a/aiopnsense/vnstat.py +++ b/aiopnsense/vnstat.py @@ -55,18 +55,6 @@ class VnstatMixin(AiopnsenseClientProtocol): """vnStat methods for OPNsenseClient.""" - async def _fetch_vnstat_for(self, endpoint: str, expected_period: str) -> dict[str, Any]: - """Fetch and parse vnStat payload for a specific endpoint and period. - - Args: - endpoint (str): API endpoint path to request. - expected_period (str): Expected period label for parser validation. - - Returns: - dict[str, Any]: Parsed payload or fallback empty mapping when endpoint is unavailable. - """ - return (await self._fetch_vnstat_for_result(endpoint, expected_period)).data - async def _fetch_vnstat_for_result( self, endpoint: str, expected_period: str ) -> CategoryResult[dict[str, Any]]: @@ -108,7 +96,7 @@ async def get_vnstat_metrics(self, period: str) -> dict[str, Any]: return {} endpoint = f"{VNSTAT_SERVICE_ENDPOINT_PREFIX}{requested_period}" - payload = await self._fetch_vnstat_for(endpoint, requested_period) + payload = (await self._fetch_vnstat_for_result(endpoint, requested_period)).data if not payload.get("interfaces"): return {} return payload From 564c730afc5b12f322e06db1217e7e563afabde3 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 20:49:45 -0400 Subject: [PATCH 11/14] Add status-aware SMART and NUT results --- aiopnsense/_typing.py | 6 ++++ aiopnsense/nut.py | 39 ++++++++++++++++++++---- aiopnsense/smart.py | 36 +++++++++++++++++----- tests/test_nut.py | 47 +++++++++++++++++++++++++++- tests/test_smart.py | 71 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 184 insertions(+), 15 deletions(-) diff --git a/aiopnsense/_typing.py b/aiopnsense/_typing.py index b2dc733..05fe7c6 100644 --- a/aiopnsense/_typing.py +++ b/aiopnsense/_typing.py @@ -142,6 +142,12 @@ async def _check_optional_post_endpoint( async def get_smart_result(self) -> CategoryResult[list[dict[str, Any]]]: ... + async def get_smart_info_result( + self, device: str, info_type: str = "a" + ) -> CategoryResult[dict[str, Any]]: ... + + async def get_nut_ups_status_result(self) -> CategoryResult[dict[str, Any]]: ... + async def get_vnstat_result(self) -> CategoryResult[MutableMapping[str, Any]]: ... async def get_unbound_blocklist_result(self) -> CategoryResult[dict[str, Any]]: ... diff --git a/aiopnsense/nut.py b/aiopnsense/nut.py index fa2f9d3..56dfb37 100644 --- a/aiopnsense/nut.py +++ b/aiopnsense/nut.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from typing import Any -from ._typing import AiopnsenseClientProtocol +from ._typing import AiopnsenseClientProtocol, CategoryResult from .helpers import _LOGGER, _log_errors NUT_DIAGNOSTICS_UPS_STATUS_ENDPOINT = "/api/nut/diagnostics/upsstatus" @@ -22,13 +22,40 @@ async def get_nut_ups_status(self) -> dict[str, Any]: dict[str, Any]: Decoded NUT UPS status payload, or an empty dictionary when the NUT diagnostics endpoint is unavailable. """ - optional_state, raw_payload = await self._check_optional_get_endpoint( - NUT_DIAGNOSTICS_UPS_STATUS_ENDPOINT + return (await self.get_nut_ups_status_result()).data + + async def get_nut_ups_status_result(self) -> CategoryResult[dict[str, Any]]: + """Return NUT UPS status with authoritative availability metadata.""" + result = CategoryResult.coerce( + await self._check_optional_get_endpoint(NUT_DIAGNOSTICS_UPS_STATUS_ENDPOINT) ) - if optional_state != "available": + if result.state != "available": _LOGGER.debug("NUT UPS status endpoint unavailable") - return {} - return self._normalize_nut_ups_status_payload(raw_payload) + return CategoryResult({}, result.state, result.authoritative) + if not isinstance(result.data, Mapping): + self._normalize_nut_ups_status_payload(result.data) + return CategoryResult({}, "malformed", False) + + raw_payload = dict(result.data) + normalized_payload = self._normalize_nut_ups_status_payload(raw_payload) + if not raw_payload: + return CategoryResult({}, "available", True) + + status_value = raw_payload.get("status") + response_value = raw_payload.get("response") + if status_value is not None and not isinstance(status_value, Mapping): + return CategoryResult(normalized_payload, "malformed", False) + if response_value is not None and not isinstance(response_value, str): + return CategoryResult(normalized_payload, "malformed", False) + if isinstance(status_value, Mapping) and status_value: + return CategoryResult(normalized_payload, "available", True) + if isinstance(response_value, str): + if normalized_payload.get("status"): + return CategoryResult(normalized_payload, "available", True) + return CategoryResult(normalized_payload, "malformed", False) + if isinstance(status_value, Mapping): + return CategoryResult(normalized_payload, "available", True) + return CategoryResult(normalized_payload, "malformed", False) @staticmethod def _normalize_nut_ups_status_payload(payload: Any) -> dict[str, Any]: diff --git a/aiopnsense/smart.py b/aiopnsense/smart.py index 092e2cb..54252fb 100644 --- a/aiopnsense/smart.py +++ b/aiopnsense/smart.py @@ -84,17 +84,39 @@ async def get_smart_info(self, device: str, info_type: str = "a") -> dict[str, A dict[str, Any]: Decoded SMART detail payload. Non-mapping outputs are wrapped under ``output`` to preserve a stable mapping API. """ + return (await self.get_smart_info_result(device, info_type=info_type)).data + + async def get_smart_info_result( + self, device: str, info_type: str = "a" + ) -> CategoryResult[dict[str, Any]]: + """Return SMART detail data with authoritative availability metadata. + + Args: + device (str): SMART device name, such as ``nvme0`` or ``ada0``. + info_type (str): SMART info selector supported by the plugin. + + Returns: + CategoryResult[dict[str, Any]]: Normalized detail data and its + authoritative availability state. + """ info_payload = { "device": device, "type": info_type, "json": True, } - info_status, response = await self._check_optional_post_endpoint( - SMART_SERVICE_INFO_ENDPOINT, - payload=info_payload, + result = CategoryResult.coerce( + await self._check_optional_post_endpoint( + SMART_SERVICE_INFO_ENDPOINT, + payload=info_payload, + ) ) - if info_status != "available" or not isinstance(response, MutableMapping): + if result.state != "available": _LOGGER.debug("SMART plugin unavailable") - return {} - output = response.get("output", {}) - return dict(output) if isinstance(output, MutableMapping) else {"output": output} + return CategoryResult({}, result.state, result.authoritative) + response = result.data + if not isinstance(response, MutableMapping) or "output" not in response: + _LOGGER.debug("SMART info response is missing a valid output envelope") + return CategoryResult({}, "malformed", False) + output = response["output"] + normalized = dict(output) if isinstance(output, MutableMapping) else {"output": output} + return CategoryResult(normalized, "available", True) diff --git a/tests/test_nut.py b/tests/test_nut.py index 5436d7a..a02fb07 100644 --- a/tests/test_nut.py +++ b/tests/test_nut.py @@ -7,7 +7,7 @@ import pytest -from aiopnsense import OPNsenseClient +from aiopnsense import CategoryResult, CategoryState, OPNsenseClient from tests.conftest import make_mock_session_client ClientType = Callable[..., OPNsenseClient] @@ -356,3 +356,48 @@ def test_normalize_nut_ups_status_payload_logs_fallback_branches( assert normalized_payload == expected assert expected_debug in caplog.text + + +@pytest.mark.asyncio +async def test_get_nut_ups_status_result_distinguishes_empty_and_malformed( + make_client: ClientType, +) -> None: + """NUT result metadata should distinguish explicit empty and invalid schemas.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult({}, "available", True) + ) + assert await client.get_nut_ups_status_result() == CategoryResult({}, "available", True) + + client._check_optional_get_endpoint.return_value = CategoryResult( + {"response": 123}, "available", True + ) + assert await client.get_nut_ups_status_result() == CategoryResult( + {"response": 123}, "malformed", False + ) + + client._check_optional_get_endpoint.return_value = CategoryResult("bad", "available", True) + assert await client.get_nut_ups_status_result() == CategoryResult({}, "malformed", False) + finally: + await client.async_close() + + +@pytest.mark.parametrize("state", ["pending", "missing", "transient"]) +@pytest.mark.asyncio +async def test_get_nut_ups_status_result_preserves_transport_state_and_wrapper( + make_client: ClientType, state: CategoryState +) -> None: + """NUT result states survive while the compatibility getter returns data only.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock( + side_effect=[ + CategoryResult({}, state, False), + CategoryResult({"status": {"ups.status": "OL"}}, "available", True), + ] + ) + assert await client.get_nut_ups_status_result() == CategoryResult({}, state, False) + assert await client.get_nut_ups_status() == {"status": {"ups.status": "OL"}} + finally: + await client.async_close() diff --git a/tests/test_smart.py b/tests/test_smart.py index 6a4e74e..de5efc9 100644 --- a/tests/test_smart.py +++ b/tests/test_smart.py @@ -5,7 +5,7 @@ import pytest -from aiopnsense import OPNsenseClient +from aiopnsense import CategoryResult, CategoryState, OPNsenseClient from tests.conftest import FakeResponse, make_mock_session_client ClientType = Callable[..., OPNsenseClient] @@ -401,3 +401,72 @@ def _post(*_args: object, **_kwargs: object) -> FakeResponse: assert calls == 1 finally: await client.async_close() + + +@pytest.mark.parametrize( + ("output", "expected"), + [ + ({"smart_status": "PASSED"}, {"smart_status": "PASSED"}), + ( + "SMART overall-health self-assessment test result: PASSED", + {"output": "SMART overall-health self-assessment test result: PASSED"}, + ), + ], +) +@pytest.mark.asyncio +async def test_get_smart_info_result_preserves_mapping_and_string_output( + make_client: ClientType, output: object, expected: dict[str, object] +) -> None: + """SMART detail results preserve mapping and wrapped string compatibility.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_post_endpoint = AsyncMock( + return_value=CategoryResult({"output": output}, "available", True) + ) + assert await client.get_smart_info_result("ada0", info_type="H") == CategoryResult( + expected, "available", True + ) + assert await client.get_smart_info("ada0", info_type="H") == expected + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_get_smart_info_result_distinguishes_empty_and_malformed( + make_client: ClientType, +) -> None: + """SMART detail metadata distinguishes explicit empty output and bad envelopes.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_post_endpoint = AsyncMock( + return_value=CategoryResult({"output": {}}, "available", True) + ) + assert await client.get_smart_info_result("ada0") == CategoryResult({}, "available", True) + + malformed_payloads: tuple[object, ...] = ({}, [], "bad") + for payload in malformed_payloads: + client._check_optional_post_endpoint.return_value = CategoryResult( + payload, "available", True + ) + assert await client.get_smart_info_result("ada0") == CategoryResult( + {}, "malformed", False + ) + finally: + await client.async_close() + + +@pytest.mark.parametrize("state", ["pending", "missing", "transient", "malformed"]) +@pytest.mark.asyncio +async def test_get_smart_info_result_preserves_non_available_state( + make_client: ClientType, state: CategoryState +) -> None: + """SMART detail results preserve optional transport and parser states.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_post_endpoint = AsyncMock( + return_value=CategoryResult({}, state, False) + ) + assert await client.get_smart_info_result("ada0") == CategoryResult({}, state, False) + assert await client.get_smart_info("ada0") == {} + finally: + await client.async_close() From 646d9d1c59217ea01e882142eacbe47f3f61522c Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 20:49:58 -0400 Subject: [PATCH 12/14] Reconcile optional DHCP provider requests --- aiopnsense/client_endpoint.py | 6 + aiopnsense/dhcp.py | 138 +++++++++++----- tests/test_category_result.py | 18 ++- tests/test_dhcp.py | 102 +++++++----- tests/test_dhcp_optional_results.py | 237 ++++++++++++++++++++++++++++ 5 files changed, 420 insertions(+), 81 deletions(-) create mode 100644 tests/test_dhcp_optional_results.py diff --git a/aiopnsense/client_endpoint.py b/aiopnsense/client_endpoint.py index a927a25..6972e29 100644 --- a/aiopnsense/client_endpoint.py +++ b/aiopnsense/client_endpoint.py @@ -64,6 +64,12 @@ async def _safe_dict_get(self, path: str) -> dict[str, Any]: ... "/api/dhcpv4/leases/searchLease", "/api/dhcpv6/leases/search_lease", "/api/dhcpv6/leases/searchLease", + "/api/kea/dhcpv4/get", + "/api/kea/dhcpv4/search_reservation", + "/api/kea/dhcpv4/searchReservation", + "/api/kea/leases4/search", + "/api/kea/leases6/search", + "/api/dnsmasq/leases/search", "/api/unbound/settings/search_dnsbl", "/api/vnstat/service/hourly", "/api/vnstat/service/daily", diff --git a/aiopnsense/dhcp.py b/aiopnsense/dhcp.py index 9e91977..6000624 100644 --- a/aiopnsense/dhcp.py +++ b/aiopnsense/dhcp.py @@ -156,12 +156,12 @@ async def get_dhcp_leases_result( leases_raw += await self._get_isc_dhcpv4_leases(opnsense_tz=opnsense_tz) leases_raw += await self._get_isc_dhcpv6_leases(opnsense_tz=opnsense_tz) leases_raw += await self._get_dnsmasq_leases(opnsense_tz=opnsense_tz) + lease_interfaces: dict[str, Any] = await self._get_kea_interfaces() source_states = list(self._dhcp_source_states_context.get() or []) finally: self._dhcp_source_states_context.reset(state_token) leases: dict[str, Any] = {} - lease_interfaces: dict[str, Any] = await self._get_kea_interfaces() for lease in leases_raw: if ( not isinstance(lease, MutableMapping) @@ -197,11 +197,6 @@ def _record_dhcp_source_state(self, state: CategoryState) -> None: if source_states is not None: source_states.append(state) - def _unavailable_dhcp_source_state(self, path: str) -> CategoryState: - """Resolve a failed source probe as confirmed missing or transient.""" - state = self._endpoint_availability.get(("get", path)) - return "missing" if state == "missing" else "transient" - @staticmethod def _aggregate_dhcp_source_states( source_states: list[CategoryState], @@ -218,32 +213,47 @@ def _aggregate_dhcp_source_states( return "missing", False async def _get_kea_interfaces(self) -> dict[str, Any]: + """Return the data from the status-aware Kea interface lookup.""" + result = await self._get_kea_interfaces_result() + self._record_dhcp_source_state(result.state) + return result.data + + async def _get_kea_interfaces_result(self) -> CategoryResult[dict[str, Any]]: """Return interfaces selected for Kea DHCPv4. Returns: dict[str, Any]: Mapping of Kea interface identifiers to display names when Kea DHCPv4 is enabled; otherwise an empty mapping. """ - if not await self._is_get_endpoint_available(KEA_DHCPV4_GET_ENDPOINT): + source_result = CategoryResult.coerce( + await self._check_optional_get_endpoint(KEA_DHCPV4_GET_ENDPOINT) + ) + if source_result.state != "available": _LOGGER.debug("Kea DHCP interface endpoint unavailable") - return {} + return CategoryResult({}, source_result.state, False) - response = await self._safe_dict_get(KEA_DHCPV4_GET_ENDPOINT) + response = source_result.data + if not isinstance(response, MutableMapping): + return CategoryResult({}, "malformed", False) lease_interfaces: dict[str, Any] = {} general = dict_get(response, "dhcpv4.general", {}) if not isinstance(general, MutableMapping): - return {} + return CategoryResult({}, "malformed", False) if not api_value_matches(general.get("enabled", "0"), "1"): - return {} + return CategoryResult({}, "available", True) interfaces = general.get("interfaces", {}) if not isinstance(interfaces, MutableMapping): - return {} + return CategoryResult({}, "malformed", False) + malformed = False for if_name, iface in interfaces.items(): if not isinstance(iface, MutableMapping): + malformed = True continue if api_value_matches(iface.get("selected", 0), "1") and iface.get("value", None): lease_interfaces[if_name] = iface.get("value") - return lease_interfaces + if malformed: + return CategoryResult(lease_interfaces, "malformed", False) + return CategoryResult(lease_interfaces, "available", True) async def _get_kea_dhcpv4_leases(self, opnsense_tz: tzinfo | None = None) -> list: """Return active IPv4 DHCP leases reported by Kea. @@ -258,7 +268,13 @@ async def _get_kea_dhcpv4_leases(self, opnsense_tz: tzinfo | None = None) -> lis addresses are omitted; lease ``type`` is ``static``, ``dynamic``, or ``unknown`` depending on reservation data. """ - return await self._get_kea_dhcp_leases( + result = await self._get_kea_dhcpv4_leases_result() + self._record_dhcp_source_state(result.state) + return result.data + + async def _get_kea_dhcpv4_leases_result(self) -> CategoryResult[list[dict[str, Any]]]: + """Return status-aware normalized Kea DHCPv4 leases.""" + return await self._get_kea_dhcp_leases_result( lease_endpoint=KEA_LEASES4_SEARCH_ENDPOINT, reservation_endpoint=KEA_DHCPV4_SEARCH_RESERVATION_ENDPOINT, reservation_camelcase_endpoint=KEA_DHCPV4_SEARCH_RESERVATION_CAMELCASE_ENDPOINT, @@ -279,7 +295,13 @@ async def _get_kea_dhcpv6_leases(self, opnsense_tz: tzinfo | None = None) -> lis ``type`` is ``static``, ``dynamic``, or ``unknown`` depending on reservation data. """ - return await self._get_kea_dhcp_leases( + result = await self._get_kea_dhcpv6_leases_result() + self._record_dhcp_source_state(result.state) + return result.data + + async def _get_kea_dhcpv6_leases_result(self) -> CategoryResult[list[dict[str, Any]]]: + """Return status-aware normalized Kea DHCPv6 leases.""" + return await self._get_kea_dhcp_leases_result( lease_endpoint=KEA_LEASES6_SEARCH_ENDPOINT, require_hardware_address=False, service_name="Kea DHCPv6", @@ -295,6 +317,29 @@ async def _get_kea_dhcp_leases( require_hardware_address: bool = True, dynamic_when_reservation_lookup_unavailable: bool = False, ) -> list: + """Return data from the status-aware generic Kea lease lookup.""" + result = await self._get_kea_dhcp_leases_result( + lease_endpoint=lease_endpoint, + service_name=service_name, + reservation_endpoint=reservation_endpoint, + reservation_camelcase_endpoint=reservation_camelcase_endpoint, + require_hardware_address=require_hardware_address, + dynamic_when_reservation_lookup_unavailable=( + dynamic_when_reservation_lookup_unavailable + ), + ) + self._record_dhcp_source_state(result.state) + return result.data + + async def _get_kea_dhcp_leases_result( + self, + lease_endpoint: str, + service_name: str, + reservation_endpoint: str | None = None, + reservation_camelcase_endpoint: str | None = None, + require_hardware_address: bool = True, + dynamic_when_reservation_lookup_unavailable: bool = False, + ) -> CategoryResult[list[dict[str, Any]]]: """Return active DHCP leases reported by a Kea lease endpoint. Args: @@ -308,17 +353,22 @@ async def _get_kea_dhcp_leases( reservation metadata is unavailable. Returns: - list: Normalized Kea lease entries for the supplied endpoint. + CategoryResult[list[dict[str, Any]]]: Normalized leases and the + combined lease/reservation endpoint state. """ - if not await self._is_get_endpoint_available(lease_endpoint): - self._record_dhcp_source_state(self._unavailable_dhcp_source_state(lease_endpoint)) + source_result = CategoryResult.coerce( + await self._check_optional_get_endpoint(lease_endpoint) + ) + if source_result.state != "available": _LOGGER.debug("%s not installed", service_name) - return [] - response = await self._safe_dict_get(lease_endpoint) + return CategoryResult([], source_result.state, False) + response = source_result.data + if not isinstance(response, MutableMapping): + return CategoryResult([], "malformed", False) if "rows" not in response or not isinstance(response["rows"], list): - self._record_dhcp_source_state("malformed") - return [] + return CategoryResult([], "malformed", False) malformed = False + auxiliary_state: CategoryState = "available" res_info: list[Any] | None if reservation_endpoint is None or reservation_camelcase_endpoint is None: res_info = None @@ -327,11 +377,20 @@ async def _get_kea_dhcp_leases( snake_case_path=reservation_endpoint, camel_case_path=reservation_camelcase_endpoint, ) - if not await self._is_get_endpoint_available(selected_reservation_endpoint): + reservation_result = CategoryResult.coerce( + await self._check_optional_get_endpoint(selected_reservation_endpoint) + ) + if reservation_result.state != "available": _LOGGER.debug("%s reservation endpoint unavailable", service_name) res_info = None + if reservation_result.state != "missing": + auxiliary_state = reservation_result.state else: - res_resp = await self._safe_dict_get(selected_reservation_endpoint) + res_resp = reservation_result.data + if not isinstance(res_resp, MutableMapping): + malformed = True + res_info = None + res_resp = {} if "rows" not in res_resp or not isinstance(res_resp["rows"], list): malformed = True _LOGGER.debug( @@ -404,8 +463,8 @@ async def _get_kea_dhcp_leases( else: lease["expires"] = lease_info.get("expire", None) leases.append(lease) - self._record_dhcp_source_state("malformed" if malformed else "available") - return leases + result_state: CategoryState = "malformed" if malformed else auxiliary_state + return CategoryResult(leases, result_state, result_state == "available") def _keep_latest_leases(self, reservations: list[dict]) -> list[dict]: """Deduplicate leases and keep the entry with the latest expiration. @@ -456,16 +515,23 @@ async def _get_dnsmasq_leases(self, opnsense_tz: tzinfo | None = None) -> list: to the latest expiration, expired rows are omitted, and lease ``type`` is derived from dnsmasq reservation metadata. """ - if not await self._is_get_endpoint_available(DNSMASQ_LEASES_SEARCH_ENDPOINT): - self._record_dhcp_source_state( - self._unavailable_dhcp_source_state(DNSMASQ_LEASES_SEARCH_ENDPOINT) - ) + result = await self._get_dnsmasq_leases_result() + self._record_dhcp_source_state(result.state) + return result.data + + async def _get_dnsmasq_leases_result(self) -> CategoryResult[list[dict[str, Any]]]: + """Return status-aware normalized dnsmasq leases.""" + source_result = CategoryResult.coerce( + await self._check_optional_get_endpoint(DNSMASQ_LEASES_SEARCH_ENDPOINT) + ) + if source_result.state != "available": _LOGGER.debug("Dnsmasq DHCP not installed") - return [] - response = await self._safe_dict_get(DNSMASQ_LEASES_SEARCH_ENDPOINT) + return CategoryResult([], source_result.state, False) + response = source_result.data + if not isinstance(response, MutableMapping): + return CategoryResult([], "malformed", False) if "rows" not in response or not isinstance(response["rows"], list): - self._record_dhcp_source_state("malformed") - return [] + return CategoryResult([], "malformed", False) leases_info: list = response["rows"] malformed = any(not isinstance(row, MutableMapping) for row in leases_info) cleaned_leases = self._keep_latest_leases(leases_info) @@ -510,8 +576,8 @@ async def _get_dnsmasq_leases(self, opnsense_tz: tzinfo | None = None) -> list: else: lease["expires"] = lease_info.get("expire", None) leases.append(lease) - self._record_dhcp_source_state("malformed" if malformed else "available") - return leases + state: CategoryState = "malformed" if malformed else "available" + return CategoryResult(leases, state, state == "available") async def _get_isc_dhcpv4_leases(self, opnsense_tz: tzinfo | None = None) -> list: """Return active IPv4 DHCP leases reported by ISC DHCP. diff --git a/tests/test_category_result.py b/tests/test_category_result.py index 12b8f86..afbaf73 100644 --- a/tests/test_category_result.py +++ b/tests/test_category_result.py @@ -160,12 +160,14 @@ async def test_dhcp_provider_invalid_rows_are_schema_malformed( state_token = client._dhcp_source_states_context.set([]) try: if provider == "kea": - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock(return_value=payload) + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult(payload, "available", True) + ) await client._get_kea_dhcp_leases("/api/kea/leases4/search", "Kea") elif provider == "dnsmasq": - client._is_get_endpoint_available = AsyncMock(return_value=True) - client._safe_dict_get = AsyncMock(return_value=payload) + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult(payload, "available", True) + ) await client._get_dnsmasq_leases() else: client._get_endpoint_path = AsyncMock(return_value=f"/{provider}") @@ -190,9 +192,13 @@ async def test_kea_reservation_provider_requires_rows_key(make_client) -> None: client = make_client() state_token = client._dhcp_source_states_context.set([]) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) client._get_endpoint_path = AsyncMock(return_value="/reservation") - client._safe_dict_get = AsyncMock(side_effect=[{"rows": []}, {}]) + client._check_optional_get_endpoint = AsyncMock( + side_effect=[ + CategoryResult({"rows": []}, "available", True), + CategoryResult({}, "available", True), + ] + ) await client._get_kea_dhcp_leases( "/api/kea/leases4/search", "Kea", diff --git a/tests/test_dhcp.py b/tests/test_dhcp.py index 842a801..7a04498 100644 --- a/tests/test_dhcp.py +++ b/tests/test_dhcp.py @@ -12,6 +12,16 @@ ClientType = Callable[..., OPNsenseClient] +def optional_get_from_safe_dict(client: OPNsenseClient) -> AsyncMock: + """Adapt legacy payload mocks to the status-aware optional GET contract.""" + + async def optional_get(path: str, **_kwargs: object) -> CategoryResult[object]: + """Return the mocked safe-dict payload as an available result.""" + return CategoryResult(await client._safe_dict_get(path), "available", True) + + return AsyncMock(side_effect=optional_get) + + @pytest.mark.asyncio async def test_dhcp_leases_and_keep_latest_and_dnsmasq(make_client: ClientType) -> None: """Cover Kea and dnsmasq lease parsing and lease de-duplication behavior. @@ -25,7 +35,7 @@ async def test_dhcp_leases_and_keep_latest_and_dnsmasq(make_client: ClientType) client, _session = make_mock_session_client(make_client) try: client._use_snake_case = True - client._is_get_endpoint_available = AsyncMock(return_value=True) + client._check_optional_get_endpoint = optional_get_from_safe_dict(client) # _get_kea_interfaces returns mapping and kea leases: one valid client._safe_dict_get = AsyncMock( side_effect=[ @@ -105,7 +115,7 @@ async def test_get_kea_leases_accepts_integer_active_state(make_client: ClientTy client, _session = make_mock_session_client(make_client) try: client._use_snake_case = True - client._is_get_endpoint_available = AsyncMock(return_value=True) + client._check_optional_get_endpoint = optional_get_from_safe_dict(client) client._safe_dict_get = AsyncMock( side_effect=[ { @@ -208,11 +218,13 @@ async def test_dhcp_endpoint_unavailable( """ client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=False) + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult({}, "missing", False) + ) client._safe_dict_get = AsyncMock() leases = await getattr(client, method_name)() assert leases == [] - client._is_get_endpoint_available.assert_awaited_once_with(endpoint) + client._check_optional_get_endpoint.assert_awaited_once_with(endpoint) client._safe_dict_get.assert_not_awaited() finally: await client.async_close() @@ -551,6 +563,7 @@ async def test_get_kea_interfaces_filters_enabled_and_selected(make_client: Clie """ client, _session = make_mock_session_client(make_client) try: + client._check_optional_get_endpoint = optional_get_from_safe_dict(client) client._safe_dict_get = AsyncMock(return_value={"dhcpv4": {"general": {"enabled": "0"}}}) assert await client._get_kea_interfaces() == {} @@ -591,7 +604,7 @@ async def test_get_kea_dhcpv4_leases_covers_invalid_dynamic_and_reservations( client, _session = make_mock_session_client(make_client) try: client._use_snake_case = True - client._is_get_endpoint_available = AsyncMock(return_value=True) + client._check_optional_get_endpoint = optional_get_from_safe_dict(client) future_ts = int(datetime.now(tz=UTC).timestamp()) + 3600 past_ts = int(datetime.now(tz=UTC).timestamp()) - 3600 @@ -682,7 +695,7 @@ async def test_get_kea_dhcpv6_leases_accepts_duid_only_rows(make_client: ClientT """ client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(return_value=True) + client._check_optional_get_endpoint = optional_get_from_safe_dict(client) future_ts = int(datetime.now(tz=UTC).timestamp()) + 3600 client._safe_dict_get = AsyncMock( return_value={ @@ -765,6 +778,7 @@ async def test_get_dnsmasq_leases_invalid_rows_and_expiry_paths(make_client: Cli """ client, _session = make_mock_session_client(make_client) try: + client._check_optional_get_endpoint = optional_get_from_safe_dict(client) client._safe_dict_get = AsyncMock(return_value={"rows": "bad-shape"}) assert await client._get_dnsmasq_leases() == [] @@ -989,18 +1003,21 @@ async def test_version_switched_dhcp_endpoints_rows_empty_when_reservation_unava client, _session = make_mock_session_client(make_client) try: client._use_snake_case = True - client._is_get_endpoint_available = AsyncMock(side_effect=[True, False]) - client._safe_dict_get = AsyncMock(return_value={"rows": []}) + client._check_optional_get_endpoint = AsyncMock( + side_effect=[ + CategoryResult({"rows": []}, "available", True), + CategoryResult({}, "missing", False), + ] + ) assert await client._get_kea_dhcpv4_leases() == [] - client._safe_dict_get.assert_awaited_once_with("/api/kea/leases4/search") - assert client._is_get_endpoint_available.await_count == 2 + assert client._check_optional_get_endpoint.await_count == 2 assert ( - client._is_get_endpoint_available.await_args_list[0].args[0] + client._check_optional_get_endpoint.await_args_list[0].args[0] == "/api/kea/leases4/search" ) assert ( - client._is_get_endpoint_available.await_args_list[1].args[0] + client._check_optional_get_endpoint.await_args_list[1].args[0] == "/api/kea/dhcpv4/search_reservation" ) @@ -1034,18 +1051,21 @@ async def test_version_switched_kea_dhcpv4_returns_leases_when_reservation_unava client, _session = make_mock_session_client(make_client) try: client._use_snake_case = True - client._is_get_endpoint_available = AsyncMock(side_effect=[True, False]) - client._safe_dict_get = AsyncMock( - return_value={ - "rows": [ - { - "state": "0", - "hwaddr": "aa:bb:cc:dd:ee:ff", - "address": "192.0.2.10", - "hostname": "host-a.", - } - ] - } + lease_payload = { + "rows": [ + { + "state": "0", + "hwaddr": "aa:bb:cc:dd:ee:ff", + "address": "192.0.2.10", + "hostname": "host-a.", + } + ] + } + client._check_optional_get_endpoint = AsyncMock( + side_effect=[ + CategoryResult(lease_payload, "available", True), + CategoryResult({}, "missing", False), + ] ) leases = await client._get_kea_dhcpv4_leases() @@ -1053,14 +1073,13 @@ async def test_version_switched_kea_dhcpv4_returns_leases_when_reservation_unava assert len(leases) == 1 assert leases[0].get("mac") == "aa:bb:cc:dd:ee:ff" assert leases[0].get("type") == "unknown" - client._safe_dict_get.assert_awaited_once_with("/api/kea/leases4/search") - assert client._is_get_endpoint_available.await_count == 2 + assert client._check_optional_get_endpoint.await_count == 2 assert ( - client._is_get_endpoint_available.await_args_list[0].args[0] + client._check_optional_get_endpoint.await_args_list[0].args[0] == "/api/kea/leases4/search" ) assert ( - client._is_get_endpoint_available.await_args_list[1].args[0] + client._check_optional_get_endpoint.await_args_list[1].args[0] == "/api/kea/dhcpv4/search_reservation" ) finally: @@ -1110,11 +1129,11 @@ async def test_dhcp_switched_endpoints_follow_selected_case( local_tz = datetime.now().astimezone().tzinfo assert local_tz is not None client._get_opnsense_timezone = AsyncMock(return_value=local_tz) - client._is_get_endpoint_available = AsyncMock(return_value=True) + client._check_optional_get_endpoint = optional_get_from_safe_dict(client) client._safe_dict_get = AsyncMock(side_effect=[{"rows": []}, {"rows": []}]) await client._get_kea_dhcpv4_leases() - assert client._safe_dict_get.await_args_list[1].args[0] == expected_kea + assert client._check_optional_get_endpoint.await_args_list[1].args[0] == expected_kea client._check_optional_get_endpoint = AsyncMock(return_value=("available", {"rows": []})) await client._get_isc_dhcpv4_leases() @@ -1194,18 +1213,23 @@ async def test_version_switched_get_kea_interfaces_endpoint_unavailable( """ client, _session = make_mock_session_client(make_client) try: - client._is_get_endpoint_available = AsyncMock(side_effect=[True, False]) - client._safe_dict_get = AsyncMock(return_value={"dhcpv4": {"general": {"enabled": "0"}}}) + client._check_optional_get_endpoint = AsyncMock( + side_effect=[ + CategoryResult({"dhcpv4": {"general": {"enabled": "0"}}}, "available", True), + CategoryResult({}, "missing", False), + ] + ) assert await client._get_kea_interfaces() == {} - client._safe_dict_get.assert_awaited_once_with("/api/kea/dhcpv4/get") - assert client._is_get_endpoint_available.await_count == 1 - assert client._is_get_endpoint_available.await_args_list[0].args[0] == "/api/kea/dhcpv4/get" + assert client._check_optional_get_endpoint.await_count == 1 + assert ( + client._check_optional_get_endpoint.await_args_list[0].args[0] == "/api/kea/dhcpv4/get" + ) - client._safe_dict_get = AsyncMock() assert await client._get_kea_interfaces() == {} - client._safe_dict_get.assert_not_awaited() - assert client._is_get_endpoint_available.await_count == 2 - assert client._is_get_endpoint_available.await_args_list[1].args[0] == "/api/kea/dhcpv4/get" + assert client._check_optional_get_endpoint.await_count == 2 + assert ( + client._check_optional_get_endpoint.await_args_list[1].args[0] == "/api/kea/dhcpv4/get" + ) finally: await client.async_close() diff --git a/tests/test_dhcp_optional_results.py b/tests/test_dhcp_optional_results.py new file mode 100644 index 0000000..829c9e9 --- /dev/null +++ b/tests/test_dhcp_optional_results.py @@ -0,0 +1,237 @@ +"""Focused tests for status-aware optional DHCP endpoint reads.""" + +import asyncio +from collections.abc import Callable, Iterator +from datetime import UTC +from unittest.mock import AsyncMock + +import pytest + +from aiopnsense import CategoryResult, CategoryState, OPNsenseClient +from aiopnsense.dhcp import ( + DNSMASQ_LEASES_SEARCH_ENDPOINT, + KEA_DHCPV4_GET_ENDPOINT, + KEA_DHCPV4_SEARCH_RESERVATION_CAMELCASE_ENDPOINT, + KEA_DHCPV4_SEARCH_RESERVATION_ENDPOINT, + KEA_LEASES4_SEARCH_ENDPOINT, + KEA_LEASES6_SEARCH_ENDPOINT, +) +from tests.conftest import make_mock_session_client + +ClientType = Callable[..., OPNsenseClient] + +DHCP_OPTIONAL_GET_ENDPOINTS = { + KEA_DHCPV4_GET_ENDPOINT, + KEA_DHCPV4_SEARCH_RESERVATION_ENDPOINT, + KEA_DHCPV4_SEARCH_RESERVATION_CAMELCASE_ENDPOINT, + KEA_LEASES4_SEARCH_ENDPOINT, + KEA_LEASES6_SEARCH_ENDPOINT, + DNSMASQ_LEASES_SEARCH_ENDPOINT, +} + + +def test_dhcp_optional_get_endpoints_are_registered() -> None: + """Every status-aware DHCP GET path is explicitly registered as optional.""" + assert DHCP_OPTIONAL_GET_ENDPOINTS <= OPNsenseClient._OPTIONAL_GET_ENDPOINTS + + +@pytest.mark.asyncio +async def test_kea_result_uses_real_optional_helper_through_disappearance_and_recovery( + make_client: ClientType, +) -> None: + """Kea result reads progress from healthy to pending, missing, then recovered.""" + client, _session = make_mock_session_client(make_client) + cache_key = ("get", KEA_LEASES6_SEARCH_ENDPOINT) + client._get_optional = AsyncMock( + side_effect=[ + CategoryResult({"rows": []}, "available", True), + CategoryResult({}, "missing", False), + CategoryResult({}, "missing", False), + CategoryResult({"rows": []}, "available", True), + ] + ) + client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=True) + try: + assert (await client._get_kea_dhcpv6_leases_result()).state == "available" + assert (await client._get_kea_dhcpv6_leases_result()).state == "pending" + assert (await client._get_kea_dhcpv6_leases_result()).state == "missing" + + client._endpoint_checked_at[cache_key] = 0.0 + recovered = await client._get_kea_dhcpv6_leases_result() + + assert recovered == CategoryResult([], "available", True) + assert client._endpoint_availability[cache_key] == "available" + assert cache_key not in client._optional_endpoint_missing_pending_confirmation + assert client._get_optional.await_count == 4 + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_dnsmasq_result_executes_registered_optional_request( + make_client: ClientType, +) -> None: + """The dnsmasq result seam reaches the real optional helper and returns data.""" + client, _session = make_mock_session_client(make_client) + client._get_optional = AsyncMock( + return_value=CategoryResult( + { + "rows": [ + { + "address": "192.0.2.20", + "if": "lan", + "expire": "never", + } + ] + }, + "available", + True, + ) + ) + try: + result = await client._get_dnsmasq_leases_result() + + assert result.state == "available" + assert result.data[0]["address"] == "192.0.2.20" + client._get_optional.assert_awaited_once_with(DNSMASQ_LEASES_SEARCH_ENDPOINT) + finally: + await client.async_close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("state", ["pending", "missing", "transient", "malformed"]) +async def test_optional_lease_results_preserve_endpoint_state( + make_client: ClientType, state: str +) -> None: + """Lease result helpers preserve every unavailable endpoint state.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult({}, state, False) + ) + + assert (await client._get_kea_dhcpv4_leases_result()).state == state + assert (await client._get_kea_dhcpv6_leases_result()).state == state + assert (await client._get_dnsmasq_leases_result()).state == state + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_kea_reservation_failure_preserves_healthy_lease_data( + make_client: ClientType, +) -> None: + """A reservation failure makes Kea non-authoritative without dropping leases.""" + client, _session = make_mock_session_client(make_client) + try: + client._use_snake_case = True + client._check_optional_get_endpoint = AsyncMock( + side_effect=[ + CategoryResult( + { + "rows": [ + { + "state": "0", + "address": "192.0.2.1", + "hwaddr": "aa:bb", + "if_name": "lan", + } + ] + }, + "available", + True, + ), + CategoryResult({}, "transient", False), + ] + ) + + result = await client._get_kea_dhcpv4_leases_result() + + assert result.state == "transient" + assert result.authoritative is False + assert result.data[0]["type"] == "unknown" + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_missing_kea_reservations_do_not_poison_provider( + make_client: ClientType, +) -> None: + """A confirmed absent reservation endpoint remains an optional capability.""" + client, _session = make_mock_session_client(make_client) + try: + client._use_snake_case = True + client._check_optional_get_endpoint = AsyncMock( + side_effect=[ + CategoryResult({"rows": []}, "available", True), + CategoryResult({}, "missing", False), + ] + ) + + assert await client._get_kea_dhcpv4_leases_result() == CategoryResult([], "available", True) + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_kea_interfaces_return_partial_malformed_data(make_client: ClientType) -> None: + """Valid interface rows survive alongside malformed configuration rows.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult( + { + "dhcpv4": { + "general": { + "enabled": "1", + "interfaces": { + "lan": {"selected": "1", "value": "LAN"}, + "bad": "not-a-mapping", + }, + } + } + }, + "available", + True, + ) + ) + + assert await client._get_kea_interfaces_result() == CategoryResult( + {"lan": "LAN"}, "malformed", False + ) + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_concurrent_dhcp_results_keep_source_states_isolated( + make_client: ClientType, +) -> None: + """Concurrent aggregate calls do not share provider state lists.""" + client, _session = make_mock_session_client(make_client) + try: + states: Iterator[CategoryState] = iter(["transient", "available"]) + + async def kea_v4(**_kwargs: object) -> list[dict[str, object]]: + """Record a distinct state in each task-local aggregation context.""" + state = next(states) + await asyncio.sleep(0) + client._record_dhcp_source_state(state) + return [] + + client._get_kea_dhcpv4_leases = AsyncMock(side_effect=kea_v4) + client._get_opnsense_timezone = AsyncMock(return_value=UTC) + client._get_kea_dhcpv6_leases = AsyncMock(return_value=[]) + client._get_isc_dhcpv4_leases = AsyncMock(return_value=[]) + client._get_isc_dhcpv6_leases = AsyncMock(return_value=[]) + client._get_dnsmasq_leases = AsyncMock(return_value=[]) + client._get_kea_interfaces = AsyncMock(return_value={}) + + results = await asyncio.gather( + client.get_dhcp_leases_result(), client.get_dhcp_leases_result() + ) + + assert {result.state for result in results} == {"available", "transient"} + finally: + await client.async_close() From 048fb08ea6b9d0c4a52f703ff05c4af13844eedd Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 20:59:39 -0400 Subject: [PATCH 13/14] Harden optional category authority checks --- aiopnsense/dhcp.py | 23 ++++-- aiopnsense/nut.py | 4 +- aiopnsense/smart.py | 8 ++- tests/test_dhcp_optional_results.py | 108 ++++++++++++++++++++++++++++ tests/test_nut.py | 19 +++++ tests/test_smart.py | 20 ++++++ 6 files changed, 173 insertions(+), 9 deletions(-) diff --git a/aiopnsense/dhcp.py b/aiopnsense/dhcp.py index 6000624..d46dc1c 100644 --- a/aiopnsense/dhcp.py +++ b/aiopnsense/dhcp.py @@ -9,7 +9,6 @@ _LOGGER, _log_errors, api_value_matches, - dict_get, get_ip_key, timestamp_to_datetime, try_to_int, @@ -235,15 +234,29 @@ async def _get_kea_interfaces_result(self) -> CategoryResult[dict[str, Any]]: response = source_result.data if not isinstance(response, MutableMapping): return CategoryResult({}, "malformed", False) - lease_interfaces: dict[str, Any] = {} - general = dict_get(response, "dhcpv4.general", {}) + dhcpv4 = response.get("dhcpv4") + if not isinstance(dhcpv4, MutableMapping): + return CategoryResult({}, "malformed", False) + general = dhcpv4.get("general") if not isinstance(general, MutableMapping): return CategoryResult({}, "malformed", False) - if not api_value_matches(general.get("enabled", "0"), "1"): + if "enabled" not in general: + return CategoryResult({}, "malformed", False) + enabled = general["enabled"] + if isinstance(enabled, bool): + is_enabled = enabled + elif isinstance(enabled, int) and enabled in {0, 1}: + is_enabled = enabled == 1 + elif isinstance(enabled, str) and enabled in {"0", "1"}: + is_enabled = enabled == "1" + else: + return CategoryResult({}, "malformed", False) + if not is_enabled: return CategoryResult({}, "available", True) - interfaces = general.get("interfaces", {}) + interfaces = general.get("interfaces") if not isinstance(interfaces, MutableMapping): return CategoryResult({}, "malformed", False) + lease_interfaces: dict[str, Any] = {} malformed = False for if_name, iface in interfaces.items(): if not isinstance(iface, MutableMapping): diff --git a/aiopnsense/nut.py b/aiopnsense/nut.py index 56dfb37..712b955 100644 --- a/aiopnsense/nut.py +++ b/aiopnsense/nut.py @@ -45,10 +45,10 @@ async def get_nut_ups_status_result(self) -> CategoryResult[dict[str, Any]]: response_value = raw_payload.get("response") if status_value is not None and not isinstance(status_value, Mapping): return CategoryResult(normalized_payload, "malformed", False) - if response_value is not None and not isinstance(response_value, str): - return CategoryResult(normalized_payload, "malformed", False) if isinstance(status_value, Mapping) and status_value: return CategoryResult(normalized_payload, "available", True) + if response_value is not None and not isinstance(response_value, str): + return CategoryResult(normalized_payload, "malformed", False) if isinstance(response_value, str): if normalized_payload.get("status"): return CategoryResult(normalized_payload, "available", True) diff --git a/aiopnsense/smart.py b/aiopnsense/smart.py index 54252fb..fcf3b44 100644 --- a/aiopnsense/smart.py +++ b/aiopnsense/smart.py @@ -118,5 +118,9 @@ async def get_smart_info_result( _LOGGER.debug("SMART info response is missing a valid output envelope") return CategoryResult({}, "malformed", False) output = response["output"] - normalized = dict(output) if isinstance(output, MutableMapping) else {"output": output} - return CategoryResult(normalized, "available", True) + if isinstance(output, MutableMapping): + return CategoryResult(dict(output), "available", True) + normalized = {"output": output} + if isinstance(output, str): + return CategoryResult(normalized, "available", True) + return CategoryResult(normalized, "malformed", False) diff --git a/tests/test_dhcp_optional_results.py b/tests/test_dhcp_optional_results.py index 829c9e9..2e55635 100644 --- a/tests/test_dhcp_optional_results.py +++ b/tests/test_dhcp_optional_results.py @@ -204,6 +204,114 @@ async def test_kea_interfaces_return_partial_malformed_data(make_client: ClientT await client.async_close() +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + {}, + {"dhcpv4": None}, + {"dhcpv4": {}}, + {"dhcpv4": {"general": None}}, + {"dhcpv4": {"general": {}}}, + {"dhcpv4": {"general": {"enabled": None}}}, + {"dhcpv4": {"general": {"enabled": "true"}}}, + {"dhcpv4": {"general": {"enabled": 2}}}, + {"dhcpv4": {"general": {"enabled": "1"}}}, + {"dhcpv4": {"general": {"enabled": True, "interfaces": []}}}, + ], +) +async def test_kea_interfaces_require_explicit_valid_config_schema( + make_client: ClientType, payload: object +) -> None: + """Missing or invalid required Kea configuration fields are malformed.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult(payload, "available", True) + ) + + assert await client._get_kea_interfaces_result() == CategoryResult({}, "malformed", False) + finally: + await client.async_close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("disabled", [False, 0, "0"]) +async def test_kea_interfaces_explicit_disabled_is_authoritative( + make_client: ClientType, disabled: object +) -> None: + """Every accepted explicit disabled representation is authoritative.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult( + {"dhcpv4": {"general": {"enabled": disabled}}}, "available", True + ) + ) + + assert await client._get_kea_interfaces_result() == CategoryResult({}, "available", True) + finally: + await client.async_close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("enabled", [True, 1, "1"]) +async def test_kea_interfaces_explicit_enabled_empty_is_authoritative( + make_client: ClientType, enabled: object +) -> None: + """Every accepted enabled representation permits an empty interface mapping.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult( + {"dhcpv4": {"general": {"enabled": enabled, "interfaces": {}}}}, + "available", + True, + ) + ) + + assert await client._get_kea_interfaces_result() == CategoryResult({}, "available", True) + finally: + await client.async_close() + + +@pytest.mark.asyncio +async def test_dhcp_aggregate_uses_real_kea_interface_authority_path( + make_client: ClientType, +) -> None: + """Malformed Kea config makes an otherwise healthy aggregate non-authoritative.""" + client, _session = make_mock_session_client(make_client) + + async def available_provider(**_kwargs: object) -> list[dict[str, object]]: + """Record one healthy lease provider.""" + client._record_dhcp_source_state("available") + return [] + + async def missing_provider(**_kwargs: object) -> list[dict[str, object]]: + """Record one confirmed absent lease provider.""" + client._record_dhcp_source_state("missing") + return [] + + try: + client._get_opnsense_timezone = AsyncMock(return_value=UTC) + client._get_kea_dhcpv4_leases = AsyncMock(side_effect=available_provider) + client._get_kea_dhcpv6_leases = AsyncMock(side_effect=missing_provider) + client._get_isc_dhcpv4_leases = AsyncMock(side_effect=missing_provider) + client._get_isc_dhcpv6_leases = AsyncMock(side_effect=missing_provider) + client._get_dnsmasq_leases = AsyncMock(side_effect=missing_provider) + client._get_optional = AsyncMock( + return_value=CategoryResult({"dhcpv4": {"general": {}}}, "available", True) + ) + + result = await client.get_dhcp_leases_result() + + assert result.state == "malformed" + assert result.authoritative is False + client._get_optional.assert_awaited_once_with(KEA_DHCPV4_GET_ENDPOINT) + finally: + await client.async_close() + + @pytest.mark.asyncio async def test_concurrent_dhcp_results_keep_source_states_isolated( make_client: ClientType, diff --git a/tests/test_nut.py b/tests/test_nut.py index a02fb07..3f6760d 100644 --- a/tests/test_nut.py +++ b/tests/test_nut.py @@ -383,6 +383,25 @@ async def test_get_nut_ups_status_result_distinguishes_empty_and_malformed( await client.async_close() +@pytest.mark.asyncio +async def test_get_nut_ups_status_result_prefers_valid_status_over_invalid_response( + make_client: ClientType, +) -> None: + """A valid structured NUT status is authoritative regardless of response metadata.""" + client, _session = make_mock_session_client(make_client) + try: + payload = {"status": {"ups.status": "OL"}, "response": 123} + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult(payload, "available", True) + ) + + assert await client.get_nut_ups_status_result() == CategoryResult( + payload, "available", True + ) + finally: + await client.async_close() + + @pytest.mark.parametrize("state", ["pending", "missing", "transient"]) @pytest.mark.asyncio async def test_get_nut_ups_status_result_preserves_transport_state_and_wrapper( diff --git a/tests/test_smart.py b/tests/test_smart.py index de5efc9..9695535 100644 --- a/tests/test_smart.py +++ b/tests/test_smart.py @@ -455,6 +455,26 @@ async def test_get_smart_info_result_distinguishes_empty_and_malformed( await client.async_close() +@pytest.mark.parametrize("output", [None, ["line"], 1, True]) +@pytest.mark.asyncio +async def test_get_smart_info_result_wraps_unsupported_output_as_malformed( + make_client: ClientType, output: object +) -> None: + """Unsupported SMART output types retain data without claiming authority.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_post_endpoint = AsyncMock( + return_value=CategoryResult({"output": output}, "available", True) + ) + + assert await client.get_smart_info_result("ada0") == CategoryResult( + {"output": output}, "malformed", False + ) + assert await client.get_smart_info("ada0") == {"output": output} + finally: + await client.async_close() + + @pytest.mark.parametrize("state", ["pending", "missing", "transient", "malformed"]) @pytest.mark.asyncio async def test_get_smart_info_result_preserves_non_available_state( From ef78eebe23ee3e6f5ef0643ea55959a967e851ba Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 19 Jul 2026 22:31:20 -0400 Subject: [PATCH 14/14] Address current endpoint review findings --- aiopnsense/client_base.py | 7 +- aiopnsense/client_endpoint.py | 8 ++- aiopnsense/dhcp.py | 4 +- aiopnsense/nut.py | 4 +- aiopnsense/unbound.py | 2 +- tests/test_category_result.py | 125 ++++++++++++++++++++++++++++++++++ tests/test_client_endpoint.py | 61 ++++++++++++++--- tests/test_dhcp.py | 54 ++++++++++++++- tests/test_nut.py | 7 ++ tests/test_speedtest.py | 43 ++++++++++-- tests/test_unbound.py | 2 +- 11 files changed, 290 insertions(+), 27 deletions(-) diff --git a/aiopnsense/client_base.py b/aiopnsense/client_base.py index 656f4d2..1a6d04d 100644 --- a/aiopnsense/client_base.py +++ b/aiopnsense/client_base.py @@ -17,6 +17,9 @@ from ._typing import CategoryState, EndpointAvailabilityState _UNSET: object = object() +_DHCP_SOURCE_STATES_CONTEXT: ContextVar[list[CategoryState] | None] = ContextVar( + "dhcp_source_states", default=None +) class ClientBaseMixin(ClientEndpointMixin, ClientQueueMixin, ClientTransportMixin): @@ -89,8 +92,8 @@ def __init__( self._optional_endpoint_missing_pending_confirmation: set[ tuple[Literal["get", "post"], str] ] = set() - self._dhcp_source_states_context: ContextVar[list[CategoryState] | None] = ContextVar( - "dhcp_source_states", default=None + self._dhcp_source_states_context: ContextVar[list[CategoryState] | None] = ( + _DHCP_SOURCE_STATES_CONTEXT ) positive_ttl = self._opts.get( "endpoint_positive_cache_ttl_seconds", DEFAULT_CACHE_TTL_SECONDS diff --git a/aiopnsense/client_endpoint.py b/aiopnsense/client_endpoint.py index 6972e29..9b1e155 100644 --- a/aiopnsense/client_endpoint.py +++ b/aiopnsense/client_endpoint.py @@ -508,9 +508,12 @@ async def _check_optional_endpoint( cache_key = self._get_endpoint_cache_key(method, cache_path) cache_lock = self._endpoint_locks.setdefault(cache_key, asyncio.Lock()) async with cache_lock: + is_payload_specific_smart_info = ( + method == "post" and path == "/api/smart/service/info" and payload is not None + ) had_confirmed_negative = self._endpoint_availability.get(cache_key) == "missing" cached_state = self._get_cached_endpoint_availability(method, cache_path, force_refresh) - if cached_state == "missing": + if cached_state == "missing" and not is_payload_specific_smart_info: return CategoryResult({}, "missing", False) was_pending = cache_key in self._optional_endpoint_missing_pending_confirmation @@ -526,6 +529,9 @@ async def _check_optional_endpoint( if optional_result.state == "transient": return optional_result + if is_payload_specific_smart_info: + return optional_result + if not was_pending and not had_confirmed_negative: self._invalidate_endpoint_observation(cache_key, f"real_{method}_404") diff --git a/aiopnsense/dhcp.py b/aiopnsense/dhcp.py index d46dc1c..d7107fb 100644 --- a/aiopnsense/dhcp.py +++ b/aiopnsense/dhcp.py @@ -214,7 +214,8 @@ def _aggregate_dhcp_source_states( async def _get_kea_interfaces(self) -> dict[str, Any]: """Return the data from the status-aware Kea interface lookup.""" result = await self._get_kea_interfaces_result() - self._record_dhcp_source_state(result.state) + if result.state != "available": + self._record_dhcp_source_state(result.state) return result.data async def _get_kea_interfaces_result(self) -> CategoryResult[dict[str, Any]]: @@ -434,6 +435,7 @@ async def _get_kea_dhcp_leases_result( lease_info.get("if_name"), str ): malformed = True + continue lease: dict[str, Any] = {} lease["address"] = lease_info.get("address", None) lease["hostname"] = ( diff --git a/aiopnsense/nut.py b/aiopnsense/nut.py index 712b955..6337cbd 100644 --- a/aiopnsense/nut.py +++ b/aiopnsense/nut.py @@ -33,8 +33,8 @@ async def get_nut_ups_status_result(self) -> CategoryResult[dict[str, Any]]: _LOGGER.debug("NUT UPS status endpoint unavailable") return CategoryResult({}, result.state, result.authoritative) if not isinstance(result.data, Mapping): - self._normalize_nut_ups_status_payload(result.data) - return CategoryResult({}, "malformed", False) + normalized_payload = self._normalize_nut_ups_status_payload(result.data) + return CategoryResult(normalized_payload, "malformed", False) raw_payload = dict(result.data) normalized_payload = self._normalize_nut_ups_status_payload(raw_payload) diff --git a/aiopnsense/unbound.py b/aiopnsense/unbound.py index 1dc8c41..16532ca 100644 --- a/aiopnsense/unbound.py +++ b/aiopnsense/unbound.py @@ -185,7 +185,7 @@ async def get_unbound_blocklist_result(self) -> CategoryResult[dict[str, Any]]: ) legacy = await self._get_unbound_blocklist_legacy() if not legacy: - return CategoryResult({"legacy": {}}, "malformed", False) + return CategoryResult({}, "malformed", False) return CategoryResult({"legacy": legacy}, "available", True) result = CategoryResult.coerce( diff --git a/tests/test_category_result.py b/tests/test_category_result.py index afbaf73..4af9002 100644 --- a/tests/test_category_result.py +++ b/tests/test_category_result.py @@ -65,6 +65,18 @@ def test_dhcp_source_authority_ignores_only_confirmed_inapplicable_sources( assert DHCPMixin._aggregate_dhcp_source_states(states) == expected +@pytest.mark.asyncio +async def test_dhcp_source_state_context_is_shared_between_clients(make_client) -> None: + """Client instances use one module-scoped request-local DHCP state context.""" + first_client = make_client() + second_client = make_client() + try: + assert first_client._dhcp_source_states_context is second_client._dhcp_source_states_context + finally: + await first_client.async_close() + await second_client.async_close() + + @pytest.mark.asyncio async def test_smart_result_preserves_valid_rows_but_marks_mixed_schema_malformed( make_client, @@ -186,6 +198,34 @@ async def test_dhcp_provider_invalid_rows_are_schema_malformed( await client.async_close() +@pytest.mark.asyncio +@pytest.mark.parametrize("provider", ["kea_interfaces", "kea_leases", "dnsmasq"]) +async def test_dhcp_provider_non_mapping_payload_is_schema_malformed( + make_client, provider: str +) -> None: + """Available DHCP payloads must be mappings before provider parsing.""" + client: OPNsenseClient = make_client() + try: + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult("bad-response", "available", True) + ) + + if provider == "kea_interfaces": + assert await client._get_kea_interfaces_result() == CategoryResult( + {}, "malformed", False + ) + elif provider == "kea_leases": + assert await client._get_kea_dhcp_leases_result( + "/api/kea/leases4/search", "Kea" + ) == CategoryResult([], "malformed", False) + else: + assert await client._get_dnsmasq_leases_result() == CategoryResult( + [], "malformed", False + ) + finally: + await client.async_close() + + @pytest.mark.asyncio async def test_kea_reservation_provider_requires_rows_key(make_client) -> None: """An available Kea reservation response without rows makes the source malformed.""" @@ -211,6 +251,47 @@ async def test_kea_reservation_provider_requires_rows_key(make_client) -> None: await client.async_close() +@pytest.mark.asyncio +async def test_kea_reservation_provider_rejects_non_mapping_payload(make_client) -> None: + """A non-mapping reservation payload is malformed while valid leases survive.""" + client: OPNsenseClient = make_client() + try: + client._get_endpoint_path = AsyncMock(return_value="/reservation") + client._check_optional_get_endpoint = AsyncMock( + side_effect=[ + CategoryResult( + { + "rows": [ + { + "state": "0", + "hwaddr": "aa:bb:cc:dd:ee:ff", + "address": "192.0.2.10", + "if_name": "em0", + } + ] + }, + "available", + True, + ), + CategoryResult("bad-response", "available", True), + ] + ) + + result = await client._get_kea_dhcp_leases_result( + "/api/kea/leases4/search", + "Kea", + "/reservation", + "/reservationCamel", + ) + + assert result.state == "malformed" + assert result.authoritative is False + assert len(result.data) == 1 + assert result.data[0]["type"] == "unknown" + finally: + await client.async_close() + + @pytest.mark.asyncio async def test_dhcp_result_keeps_healthy_source_data_when_another_source_is_malformed( make_client, @@ -253,6 +334,50 @@ async def provider(**_kwargs) -> list[dict]: await client.async_close() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("interface_state", "expected_state"), + [ + ("available", "missing"), + ("pending", "pending"), + ("transient", "transient"), + ("malformed", "malformed"), + ("missing", "missing"), + ], +) +async def test_dhcp_result_treats_kea_interfaces_as_metadata_only( + make_client, interface_state: CategoryState, expected_state: CategoryState +) -> None: + """Kea interface metadata cannot make missing lease providers authoritative.""" + client = make_client() + + async def missing_provider(**_kwargs) -> list[dict]: + """Record a missing lease provider.""" + client._record_dhcp_source_state("missing") + return [] + + try: + client._get_opnsense_timezone = AsyncMock(return_value=UTC) + for name in ( + "_get_kea_dhcpv4_leases", + "_get_kea_dhcpv6_leases", + "_get_isc_dhcpv4_leases", + "_get_isc_dhcpv6_leases", + "_get_dnsmasq_leases", + ): + setattr(client, name, AsyncMock(side_effect=missing_provider)) + client._get_kea_interfaces_result = AsyncMock( + return_value=CategoryResult({}, interface_state, interface_state == "available") + ) + + result = await client.get_dhcp_leases_result() + + assert result.state == expected_state + assert result.authoritative is False + finally: + await client.async_close() + + @pytest.mark.asyncio @pytest.mark.parametrize("state", ["pending", "missing", "transient", "malformed"]) async def test_arp_result_propagates_non_available_endpoint_states( diff --git a/tests/test_client_endpoint.py b/tests/test_client_endpoint.py index 835555f..ca0e1e0 100644 --- a/tests/test_client_endpoint.py +++ b/tests/test_client_endpoint.py @@ -1094,18 +1094,20 @@ async def test_check_optional_post_endpoint_confirms_second_404_with_core_health ) -> None: """A second read-only POST 404 plus healthy core stores a short negative.""" client, _session = make_mock_session_client(make_client) - cache_key = ("post", "/api/smart/service/info") + cache_key = ("post", "/api/smart/service/list") client._post_optional = AsyncMock(return_value=("missing", {})) client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=True) try: - assert await client._check_optional_post_endpoint( - "/api/smart/service/info", payload={"device": "ada0"} - ) == ("pending", {}) + assert await client._check_optional_post_endpoint("/api/smart/service/list") == ( + "pending", + {}, + ) assert cache_key in client._optional_endpoint_missing_pending_confirmation - assert await client._check_optional_post_endpoint( - "/api/smart/service/info", payload={"device": "ada0"} - ) == ("missing", {}) + assert await client._check_optional_post_endpoint("/api/smart/service/list") == ( + "missing", + {}, + ) assert client._endpoint_availability[cache_key] == "missing" assert cache_key not in client._optional_endpoint_missing_pending_confirmation assert client._post_optional.await_count == 2 @@ -1120,7 +1122,7 @@ async def test_check_optional_post_endpoint_stale_negative_renews_with_one_probe ) -> None: """An expired SMART absence is renewed with one read-only POST probe.""" client, _session = make_mock_session_client(make_client) - path = "/api/smart/service/info" + path = "/api/smart/service/list" cache_key = ("post", path) client._endpoint_availability[cache_key] = "missing" client._endpoint_checked_at[cache_key] = 1000.0 - (DEFAULT_NEGATIVE_CACHE_TTL_SECONDS + 1) @@ -1128,22 +1130,59 @@ async def test_check_optional_post_endpoint_stale_negative_renews_with_one_probe monkeypatch.setattr("aiopnsense.client_endpoint.monotonic", lambda: next(times)) client._post_optional = AsyncMock(return_value=("missing", {})) client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=True) - payload = {"device": "ada0"} try: - assert await client._check_optional_post_endpoint(path, payload=payload) == ( + assert await client._check_optional_post_endpoint(path) == ( "missing", {}, ) assert client._endpoint_availability[cache_key] == "missing" assert client._endpoint_checked_at[cache_key] == 1001.0 assert cache_key not in client._optional_endpoint_missing_pending_confirmation - client._post_optional.assert_awaited_once_with(path, payload) + client._post_optional.assert_awaited_once_with(path, None) client._is_core_firmware_endpoint_healthy.assert_awaited_once_with() finally: await client.async_close() +@pytest.mark.asyncio +async def test_check_optional_smart_info_missing_payload_does_not_cache( + make_client: MakeClientFactory, +) -> None: + """A device-specific SMART miss does not block another device request.""" + client, _session = make_mock_session_client(make_client) + path = "/api/smart/service/info" + cache_key = ("post", path) + stale_payload = {"device": "ada0"} + available_payload = {"device": "ada1"} + client._post_optional = AsyncMock( + side_effect=[("missing", {}), ("available", {"temperature": 30})] + ) + client._is_core_firmware_endpoint_healthy = AsyncMock(return_value=True) + + try: + assert await client._check_optional_post_endpoint(path, payload=stale_payload) == ( + "missing", + {}, + ) + assert cache_key not in client._endpoint_availability + assert cache_key not in client._endpoint_checked_at + assert cache_key not in client._optional_endpoint_missing_pending_confirmation + + assert await client._check_optional_post_endpoint(path, payload=available_payload) == ( + "available", + {"temperature": 30}, + ) + assert client._endpoint_availability[cache_key] == "available" + assert client._post_optional.await_args_list == [ + ((path, stale_payload),), + ((path, available_payload),), + ] + client._is_core_firmware_endpoint_healthy.assert_not_awaited() + finally: + await client.async_close() + + @pytest.mark.asyncio async def test_check_optional_post_endpoint_transient_failure_preserves_positive( make_client: MakeClientFactory, diff --git a/tests/test_dhcp.py b/tests/test_dhcp.py index 7a04498..613fe0c 100644 --- a/tests/test_dhcp.py +++ b/tests/test_dhcp.py @@ -671,7 +671,16 @@ async def test_get_kea_dhcpv4_leases_covers_invalid_dynamic_and_reservations( # Reservation lookup failures should not misclassify entries as dynamic/static. client._safe_dict_get = AsyncMock( side_effect=[ - {"rows": [{"state": "0", "hwaddr": "aa", "address": "10.0.0.1"}]}, + { + "rows": [ + { + "state": "0", + "hwaddr": "aa", + "address": "10.0.0.1", + "if_name": "em0", + } + ] + }, {"rows": "bad"}, ] ) @@ -766,6 +775,48 @@ async def test_get_kea_dhcpv6_leases_accepts_duid_only_rows(make_client: ClientT await client.async_close() +@pytest.mark.asyncio +async def test_get_kea_dhcpv6_leases_omits_invalid_required_fields( + make_client: ClientType, +) -> None: + """Invalid Kea address and interface fields are omitted and mark partial data malformed.""" + client, _session = make_mock_session_client(make_client) + try: + client._check_optional_get_endpoint = AsyncMock( + return_value=CategoryResult( + { + "rows": [ + {"state": 0, "address": None, "if_name": "em0"}, + {"state": 0, "address": "2001:db8::2", "if_name": None}, + {"state": 0, "address": "2001:db8::3", "if_name": "em0"}, + ] + }, + "available", + True, + ) + ) + + result = await client._get_kea_dhcpv6_leases_result() + + assert result == CategoryResult( + [ + { + "address": "2001:db8::3", + "hostname": None, + "if_descr": None, + "if_name": "em0", + "type": "unknown", + "mac": None, + "expires": None, + } + ], + "malformed", + False, + ) + finally: + await client.async_close() + + @pytest.mark.asyncio async def test_get_dnsmasq_leases_invalid_rows_and_expiry_paths(make_client: ClientType) -> None: """Verify dnsmasq lease parsing handles malformed and expired rows safely. @@ -1057,6 +1108,7 @@ async def test_version_switched_kea_dhcpv4_returns_leases_when_reservation_unava "state": "0", "hwaddr": "aa:bb:cc:dd:ee:ff", "address": "192.0.2.10", + "if_name": "em0", "hostname": "host-a.", } ] diff --git a/tests/test_nut.py b/tests/test_nut.py index 3f6760d..dd12ce7 100644 --- a/tests/test_nut.py +++ b/tests/test_nut.py @@ -370,6 +370,13 @@ async def test_get_nut_ups_status_result_distinguishes_empty_and_malformed( ) assert await client.get_nut_ups_status_result() == CategoryResult({}, "available", True) + client._check_optional_get_endpoint.return_value = CategoryResult( + {"status": {}}, "available", True + ) + assert await client.get_nut_ups_status_result() == CategoryResult( + {"status": {}}, "available", True + ) + client._check_optional_get_endpoint.return_value = CategoryResult( {"response": 123}, "available", True ) diff --git a/tests/test_speedtest.py b/tests/test_speedtest.py index 852171d..dbfb26c 100644 --- a/tests/test_speedtest.py +++ b/tests/test_speedtest.py @@ -121,7 +121,19 @@ async def test_get_speedtest_normalizes_latest_and_stat_payloads(make_client) -> ] ], ), - ("available", {}), + ( + "available", + { + "samples": 42, + "period": { + "oldest": "2026-03-01 00:00:00", + "youngest": "2026-03-14 03:09:45", + }, + "download": {"avg": 100.5, "min": 90.0, "max": 110.0}, + "upload": {"avg": 20.5, "min": 18.0, "max": 23.0}, + "latency": {"avg": 3.5, "min": 2.0, "max": 5.0}, + }, + ), ], True, id="showstat-available", @@ -202,14 +214,31 @@ async def test_get_speedtest_probes_showstat_before_fetching_optional_payload( call("/api/speedtest/service/showstat"), ] + assert result["last"]["download"]["value"] == 1.0 + assert result["last"]["upload"]["value"] == 2.0 + assert result["last"]["latency"]["value"] == 3.0 + if showstat_available: - assert result["last"]["download"]["value"] == 1.0 - assert result["last"]["upload"]["value"] == 2.0 - assert result["last"]["latency"]["value"] == 3.0 + assert result["average"]["download"] == { + "value": 100.5, + "min": 90.0, + "max": 110.0, + "oldest": "2026-03-01 00:00:00", + "youngest": "2026-03-14 03:09:45", + "samples": 42, + } + assert result["average"]["upload"]["value"] == 20.5 + assert result["average"]["latency"]["value"] == 3.5 else: - assert result["last"]["download"]["value"] == 1.0 - assert result["last"]["upload"]["value"] == 2.0 - assert result["last"]["latency"]["value"] == 3.0 + for metric in ("download", "upload", "latency"): + assert result["average"][metric] == { + "value": None, + "min": None, + "max": None, + "oldest": None, + "youngest": None, + "samples": None, + } finally: await client.async_close() diff --git a/tests/test_unbound.py b/tests/test_unbound.py index f7d2bab..6e2033f 100644 --- a/tests/test_unbound.py +++ b/tests/test_unbound.py @@ -255,7 +255,7 @@ async def test_get_unbound_blocklist_returns_empty_for_invalid_legacy_payloads( result = await client.get_unbound_blocklist() - assert result == {"legacy": {}} + assert result == {} client._safe_dict_get.assert_awaited_once_with("/api/unbound/settings/get") finally: await client.async_close()