mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-02-15 07:27:13 +00:00
Add D-Bus tolerant enum base classes to prevent crashes on unknown values (#6545)
* Add D-Bus tolerant enum base classes to prevent crashes on unknown values D-Bus services (systemd, NetworkManager, RAUC, UDisks2) can introduce new enum values at any time via OS updates. Standard Python enum construction raises ValueError for unknown values, which would crash the Supervisor. Introduce DBusStrEnum and DBusIntEnum base classes that use Python's _missing_ hook to create pseudo-members for unknown values. These pseudo-members pass isinstance checks (satisfying typeguard), preserve the original value, don't pollute __members__, and report unknown values to Sentry (deduplicated per class+value) for observability. Migrate 17 D-Bus enums in dbus/const.py and udisks2/const.py to the new base classes. Enums only sent TO D-Bus (StopUnitMode, StartUnitMode, etc.) are left unchanged. Remove the manual try/except workaround in NetworkInterface.type now that DBusIntEnum handles it automatically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add explicit enum conversions for systemd-resolved D-Bus properties The resolved properties (dns_over_tls, dns_stub_listener, dnssec, llmnr, multicast_dns, resolv_conf_mode) were returning raw string values from D-Bus without converting to their declared enum types. This would fail runtime type checking with typeguard. Now safe to add explicit conversions since these enums use DBusStrEnum, which tolerates unknown values from D-Bus without crashing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Avoid blocking I/O in D-Bus enum Sentry reporting Move sentry_sdk.capture_message out of the event loop by adding a fire_and_forget_capture_message helper that offloads the call to the executor when a running loop is detected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Handle exceptions when reporting message to Sentry * Narrow typing of reported values Use str/int explicitly since that is what the two existing Enum classes can actually report. * Adjust test style * Apply suggestions from code review --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
from enum import IntEnum, StrEnum
|
from enum import IntEnum, StrEnum
|
||||||
from socket import AF_INET, AF_INET6
|
from socket import AF_INET, AF_INET6
|
||||||
|
|
||||||
|
from .enum import DBusIntEnum, DBusStrEnum
|
||||||
|
|
||||||
DBUS_NAME_HAOS = "io.hass.os"
|
DBUS_NAME_HAOS = "io.hass.os"
|
||||||
DBUS_NAME_HOSTNAME = "org.freedesktop.hostname1"
|
DBUS_NAME_HOSTNAME = "org.freedesktop.hostname1"
|
||||||
DBUS_NAME_LOGIND = "org.freedesktop.login1"
|
DBUS_NAME_LOGIND = "org.freedesktop.login1"
|
||||||
@@ -208,7 +210,7 @@ DBUS_ATTR_WWN = "WWN"
|
|||||||
DBUS_ERR_SYSTEMD_NO_SUCH_UNIT = "org.freedesktop.systemd1.NoSuchUnit"
|
DBUS_ERR_SYSTEMD_NO_SUCH_UNIT = "org.freedesktop.systemd1.NoSuchUnit"
|
||||||
|
|
||||||
|
|
||||||
class RaucState(StrEnum):
|
class RaucState(DBusStrEnum):
|
||||||
"""Rauc slot states."""
|
"""Rauc slot states."""
|
||||||
|
|
||||||
GOOD = "good"
|
GOOD = "good"
|
||||||
@@ -216,7 +218,7 @@ class RaucState(StrEnum):
|
|||||||
ACTIVE = "active"
|
ACTIVE = "active"
|
||||||
|
|
||||||
|
|
||||||
class InterfaceMethod(StrEnum):
|
class InterfaceMethod(DBusStrEnum):
|
||||||
"""Interface method simple."""
|
"""Interface method simple."""
|
||||||
|
|
||||||
AUTO = "auto"
|
AUTO = "auto"
|
||||||
@@ -225,7 +227,7 @@ class InterfaceMethod(StrEnum):
|
|||||||
LINK_LOCAL = "link-local"
|
LINK_LOCAL = "link-local"
|
||||||
|
|
||||||
|
|
||||||
class InterfaceAddrGenMode(IntEnum):
|
class InterfaceAddrGenMode(DBusIntEnum):
|
||||||
"""Interface addr_gen_mode."""
|
"""Interface addr_gen_mode."""
|
||||||
|
|
||||||
EUI64 = 0
|
EUI64 = 0
|
||||||
@@ -234,7 +236,7 @@ class InterfaceAddrGenMode(IntEnum):
|
|||||||
DEFAULT = 3
|
DEFAULT = 3
|
||||||
|
|
||||||
|
|
||||||
class InterfaceIp6Privacy(IntEnum):
|
class InterfaceIp6Privacy(DBusIntEnum):
|
||||||
"""Interface ip6_privacy."""
|
"""Interface ip6_privacy."""
|
||||||
|
|
||||||
DEFAULT = -1
|
DEFAULT = -1
|
||||||
@@ -243,14 +245,14 @@ class InterfaceIp6Privacy(IntEnum):
|
|||||||
ENABLED = 2
|
ENABLED = 2
|
||||||
|
|
||||||
|
|
||||||
class ConnectionType(StrEnum):
|
class ConnectionType(DBusStrEnum):
|
||||||
"""Connection type."""
|
"""Connection type."""
|
||||||
|
|
||||||
ETHERNET = "802-3-ethernet"
|
ETHERNET = "802-3-ethernet"
|
||||||
WIRELESS = "802-11-wireless"
|
WIRELESS = "802-11-wireless"
|
||||||
|
|
||||||
|
|
||||||
class ConnectionState(IntEnum):
|
class ConnectionState(DBusIntEnum):
|
||||||
"""Connection states.
|
"""Connection states.
|
||||||
|
|
||||||
https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMActiveConnectionState
|
https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMActiveConnectionState
|
||||||
@@ -280,7 +282,7 @@ class ConnectionStateFlags(IntEnum):
|
|||||||
EXTERNAL = 0x80
|
EXTERNAL = 0x80
|
||||||
|
|
||||||
|
|
||||||
class ConnectivityState(IntEnum):
|
class ConnectivityState(DBusIntEnum):
|
||||||
"""Network connectvity.
|
"""Network connectvity.
|
||||||
|
|
||||||
https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMConnectivityState
|
https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMConnectivityState
|
||||||
@@ -293,7 +295,7 @@ class ConnectivityState(IntEnum):
|
|||||||
CONNECTIVITY_FULL = 4
|
CONNECTIVITY_FULL = 4
|
||||||
|
|
||||||
|
|
||||||
class DeviceType(IntEnum):
|
class DeviceType(DBusIntEnum):
|
||||||
"""Device types.
|
"""Device types.
|
||||||
|
|
||||||
https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceType
|
https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceType
|
||||||
@@ -310,7 +312,7 @@ class DeviceType(IntEnum):
|
|||||||
LOOPBACK = 32
|
LOOPBACK = 32
|
||||||
|
|
||||||
|
|
||||||
class WirelessMethodType(IntEnum):
|
class WirelessMethodType(DBusIntEnum):
|
||||||
"""Device Type."""
|
"""Device Type."""
|
||||||
|
|
||||||
UNKNOWN = 0
|
UNKNOWN = 0
|
||||||
@@ -327,7 +329,7 @@ class DNSAddressFamily(IntEnum):
|
|||||||
INET6 = AF_INET6
|
INET6 = AF_INET6
|
||||||
|
|
||||||
|
|
||||||
class MulticastProtocolEnabled(StrEnum):
|
class MulticastProtocolEnabled(DBusStrEnum):
|
||||||
"""Multicast protocol enabled or resolve."""
|
"""Multicast protocol enabled or resolve."""
|
||||||
|
|
||||||
YES = "yes"
|
YES = "yes"
|
||||||
@@ -335,7 +337,7 @@ class MulticastProtocolEnabled(StrEnum):
|
|||||||
RESOLVE = "resolve"
|
RESOLVE = "resolve"
|
||||||
|
|
||||||
|
|
||||||
class MulticastDnsValue(IntEnum):
|
class MulticastDnsValue(DBusIntEnum):
|
||||||
"""Connection MulticastDNS (mdns/llmnr) values."""
|
"""Connection MulticastDNS (mdns/llmnr) values."""
|
||||||
|
|
||||||
DEFAULT = -1
|
DEFAULT = -1
|
||||||
@@ -344,7 +346,7 @@ class MulticastDnsValue(IntEnum):
|
|||||||
ANNOUNCE = 2
|
ANNOUNCE = 2
|
||||||
|
|
||||||
|
|
||||||
class DNSOverTLSEnabled(StrEnum):
|
class DNSOverTLSEnabled(DBusStrEnum):
|
||||||
"""DNS over TLS enabled."""
|
"""DNS over TLS enabled."""
|
||||||
|
|
||||||
YES = "yes"
|
YES = "yes"
|
||||||
@@ -352,7 +354,7 @@ class DNSOverTLSEnabled(StrEnum):
|
|||||||
OPPORTUNISTIC = "opportunistic"
|
OPPORTUNISTIC = "opportunistic"
|
||||||
|
|
||||||
|
|
||||||
class DNSSECValidation(StrEnum):
|
class DNSSECValidation(DBusStrEnum):
|
||||||
"""DNSSEC validation enforced."""
|
"""DNSSEC validation enforced."""
|
||||||
|
|
||||||
YES = "yes"
|
YES = "yes"
|
||||||
@@ -360,7 +362,7 @@ class DNSSECValidation(StrEnum):
|
|||||||
ALLOW_DOWNGRADE = "allow-downgrade"
|
ALLOW_DOWNGRADE = "allow-downgrade"
|
||||||
|
|
||||||
|
|
||||||
class DNSStubListenerEnabled(StrEnum):
|
class DNSStubListenerEnabled(DBusStrEnum):
|
||||||
"""DNS stub listener enabled."""
|
"""DNS stub listener enabled."""
|
||||||
|
|
||||||
YES = "yes"
|
YES = "yes"
|
||||||
@@ -369,7 +371,7 @@ class DNSStubListenerEnabled(StrEnum):
|
|||||||
UDP_ONLY = "udp"
|
UDP_ONLY = "udp"
|
||||||
|
|
||||||
|
|
||||||
class ResolvConfMode(StrEnum):
|
class ResolvConfMode(DBusStrEnum):
|
||||||
"""Resolv.conf management mode."""
|
"""Resolv.conf management mode."""
|
||||||
|
|
||||||
FOREIGN = "foreign"
|
FOREIGN = "foreign"
|
||||||
@@ -398,7 +400,7 @@ class StartUnitMode(StrEnum):
|
|||||||
ISOLATE = "isolate"
|
ISOLATE = "isolate"
|
||||||
|
|
||||||
|
|
||||||
class UnitActiveState(StrEnum):
|
class UnitActiveState(DBusStrEnum):
|
||||||
"""Active state of a systemd unit."""
|
"""Active state of a systemd unit."""
|
||||||
|
|
||||||
ACTIVE = "active"
|
ACTIVE = "active"
|
||||||
|
|||||||
56
supervisor/dbus/enum.py
Normal file
56
supervisor/dbus/enum.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""D-Bus tolerant enum base classes.
|
||||||
|
|
||||||
|
D-Bus services (systemd, NetworkManager, RAUC, UDisks2) can introduce new enum
|
||||||
|
values at any time via OS updates. Standard enum construction raises ValueError
|
||||||
|
for unknown values. These base classes use Python's _missing_ hook to create
|
||||||
|
pseudo-members for unknown values, preventing crashes while preserving the
|
||||||
|
original value for logging and debugging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import IntEnum, StrEnum
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..utils.sentry import fire_and_forget_capture_message
|
||||||
|
|
||||||
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_reported: set[tuple[str, str | int]] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _report_unknown_value(cls: type, value: str | int) -> None:
|
||||||
|
"""Log and report an unknown D-Bus enum value to Sentry."""
|
||||||
|
msg = f"Unknown {cls.__name__} value received from D-Bus: {value}"
|
||||||
|
_LOGGER.warning(msg)
|
||||||
|
|
||||||
|
key = (cls.__name__, value)
|
||||||
|
if key not in _reported:
|
||||||
|
_reported.add(key)
|
||||||
|
fire_and_forget_capture_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class DBusStrEnum(StrEnum):
|
||||||
|
"""StrEnum that tolerates unknown values from D-Bus."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _missing_(cls, value: object) -> "DBusStrEnum | None":
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
_report_unknown_value(cls, value)
|
||||||
|
obj = str.__new__(cls, value)
|
||||||
|
obj._name_ = value
|
||||||
|
obj._value_ = value
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
class DBusIntEnum(IntEnum):
|
||||||
|
"""IntEnum that tolerates unknown values from D-Bus."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _missing_(cls, value: object) -> "DBusIntEnum | None":
|
||||||
|
if not isinstance(value, int):
|
||||||
|
return None
|
||||||
|
_report_unknown_value(cls, value)
|
||||||
|
obj = int.__new__(cls, value)
|
||||||
|
obj._name_ = f"UNKNOWN_{value}"
|
||||||
|
obj._value_ = value
|
||||||
|
return obj
|
||||||
@@ -60,15 +60,7 @@ class NetworkInterface(DBusInterfaceProxy):
|
|||||||
@dbus_property
|
@dbus_property
|
||||||
def type(self) -> DeviceType:
|
def type(self) -> DeviceType:
|
||||||
"""Return interface type."""
|
"""Return interface type."""
|
||||||
try:
|
return DeviceType(self.properties[DBUS_ATTR_DEVICE_TYPE])
|
||||||
return DeviceType(self.properties[DBUS_ATTR_DEVICE_TYPE])
|
|
||||||
except ValueError:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Unknown device type %s for %s, treating as UNKNOWN",
|
|
||||||
self.properties[DBUS_ATTR_DEVICE_TYPE],
|
|
||||||
self.object_path,
|
|
||||||
)
|
|
||||||
return DeviceType.UNKNOWN
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@dbus_property
|
@dbus_property
|
||||||
|
|||||||
@@ -103,19 +103,19 @@ class Resolved(DBusInterfaceProxy):
|
|||||||
@dbus_property
|
@dbus_property
|
||||||
def dns_over_tls(self) -> DNSOverTLSEnabled | None:
|
def dns_over_tls(self) -> DNSOverTLSEnabled | None:
|
||||||
"""Return DNS over TLS enabled."""
|
"""Return DNS over TLS enabled."""
|
||||||
return self.properties[DBUS_ATTR_DNS_OVER_TLS]
|
return DNSOverTLSEnabled(self.properties[DBUS_ATTR_DNS_OVER_TLS])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@dbus_property
|
@dbus_property
|
||||||
def dns_stub_listener(self) -> DNSStubListenerEnabled | None:
|
def dns_stub_listener(self) -> DNSStubListenerEnabled | None:
|
||||||
"""Return DNS stub listener enabled on port 53."""
|
"""Return DNS stub listener enabled on port 53."""
|
||||||
return self.properties[DBUS_ATTR_DNS_STUB_LISTENER]
|
return DNSStubListenerEnabled(self.properties[DBUS_ATTR_DNS_STUB_LISTENER])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@dbus_property
|
@dbus_property
|
||||||
def dnssec(self) -> DNSSECValidation | None:
|
def dnssec(self) -> DNSSECValidation | None:
|
||||||
"""Return DNSSEC validation enforced."""
|
"""Return DNSSEC validation enforced."""
|
||||||
return self.properties[DBUS_ATTR_DNSSEC]
|
return DNSSECValidation(self.properties[DBUS_ATTR_DNSSEC])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@dbus_property
|
@dbus_property
|
||||||
@@ -159,7 +159,7 @@ class Resolved(DBusInterfaceProxy):
|
|||||||
@dbus_property
|
@dbus_property
|
||||||
def llmnr(self) -> MulticastProtocolEnabled | None:
|
def llmnr(self) -> MulticastProtocolEnabled | None:
|
||||||
"""Return LLMNR enabled."""
|
"""Return LLMNR enabled."""
|
||||||
return self.properties[DBUS_ATTR_LLMNR]
|
return MulticastProtocolEnabled(self.properties[DBUS_ATTR_LLMNR])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@dbus_property
|
@dbus_property
|
||||||
@@ -171,13 +171,13 @@ class Resolved(DBusInterfaceProxy):
|
|||||||
@dbus_property
|
@dbus_property
|
||||||
def multicast_dns(self) -> MulticastProtocolEnabled | None:
|
def multicast_dns(self) -> MulticastProtocolEnabled | None:
|
||||||
"""Return MDNS enabled."""
|
"""Return MDNS enabled."""
|
||||||
return self.properties[DBUS_ATTR_MULTICAST_DNS]
|
return MulticastProtocolEnabled(self.properties[DBUS_ATTR_MULTICAST_DNS])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@dbus_property
|
@dbus_property
|
||||||
def resolv_conf_mode(self) -> ResolvConfMode | None:
|
def resolv_conf_mode(self) -> ResolvConfMode | None:
|
||||||
"""Return how /etc/resolv.conf managed on host."""
|
"""Return how /etc/resolv.conf managed on host."""
|
||||||
return self.properties[DBUS_ATTR_RESOLV_CONF_MODE]
|
return ResolvConfMode(self.properties[DBUS_ATTR_RESOLV_CONF_MODE])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@dbus_property
|
@dbus_property
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from enum import StrEnum
|
|||||||
|
|
||||||
from dbus_fast import Variant
|
from dbus_fast import Variant
|
||||||
|
|
||||||
|
from ..enum import DBusStrEnum
|
||||||
|
|
||||||
UDISKS2_DEFAULT_OPTIONS = {"auth.no_user_interaction": Variant("b", True)}
|
UDISKS2_DEFAULT_OPTIONS = {"auth.no_user_interaction": Variant("b", True)}
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ class FormatType(StrEnum):
|
|||||||
GPT = "gpt"
|
GPT = "gpt"
|
||||||
|
|
||||||
|
|
||||||
class PartitionTableType(StrEnum):
|
class PartitionTableType(DBusStrEnum):
|
||||||
"""Partition Table type."""
|
"""Partition Table type."""
|
||||||
|
|
||||||
DOS = "dos"
|
DOS = "dos"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from aiohttp.web_exceptions import HTTPBadGateway, HTTPServiceUnavailable
|
from aiohttp.web_exceptions import HTTPBadGateway, HTTPServiceUnavailable
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
@@ -78,6 +79,33 @@ async def async_capture_exception(err: BaseException) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fire_and_forget_capture_message(
|
||||||
|
msg: str,
|
||||||
|
level: Literal["fatal", "critical", "error", "warning", "info", "debug"]
|
||||||
|
| None = "warning",
|
||||||
|
) -> None:
|
||||||
|
"""Capture a message and send to sentry without blocking the event loop.
|
||||||
|
|
||||||
|
Safe to call from sync code running in the event loop. The executor future
|
||||||
|
is intentionally not awaited (fire-and-forget).
|
||||||
|
"""
|
||||||
|
if not sentry_sdk.is_initialized():
|
||||||
|
return
|
||||||
|
|
||||||
|
def _capture() -> None:
|
||||||
|
try:
|
||||||
|
sentry_sdk.capture_message(msg, level=level)
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.debug("Failed to send message to Sentry: %s", msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
_capture()
|
||||||
|
else:
|
||||||
|
loop.run_in_executor(None, _capture)
|
||||||
|
|
||||||
|
|
||||||
def close_sentry() -> None:
|
def close_sentry() -> None:
|
||||||
"""Close the current sentry client.
|
"""Close the current sentry client.
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,8 @@ async def test_unknown_device_type(
|
|||||||
device_eth0_service.emit_properties_changed({"DeviceType": 1000})
|
device_eth0_service.emit_properties_changed({"DeviceType": 1000})
|
||||||
await device_eth0_service.ping()
|
await device_eth0_service.ping()
|
||||||
|
|
||||||
# Should return UNKNOWN instead of crashing
|
# Should preserve the actual value as a pseudo-member instead of crashing
|
||||||
assert interface.type == DeviceType.UNKNOWN
|
assert isinstance(interface.type, DeviceType)
|
||||||
|
assert interface.type == 1000
|
||||||
# Wireless should be None since it's not a wireless device
|
# Wireless should be None since it's not a wireless device
|
||||||
assert interface.wireless is None
|
assert interface.wireless is None
|
||||||
|
|||||||
324
tests/dbus/test_enum.py
Normal file
324
tests/dbus/test_enum.py
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
"""Tests for D-Bus tolerant enum base classes."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from supervisor.dbus.const import (
|
||||||
|
ConnectionState,
|
||||||
|
ConnectionType,
|
||||||
|
ConnectivityState,
|
||||||
|
DeviceType,
|
||||||
|
DNSOverTLSEnabled,
|
||||||
|
InterfaceMethod,
|
||||||
|
MulticastProtocolEnabled,
|
||||||
|
RaucState,
|
||||||
|
UnitActiveState,
|
||||||
|
WirelessMethodType,
|
||||||
|
)
|
||||||
|
from supervisor.dbus.enum import DBusIntEnum, DBusStrEnum, _reported
|
||||||
|
from supervisor.dbus.udisks2.const import PartitionTableType
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_reported():
|
||||||
|
"""Clear the deduplication set between tests."""
|
||||||
|
_reported.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Test fixtures: concrete subclasses for isolated testing --
|
||||||
|
|
||||||
|
|
||||||
|
class SampleStrEnum(DBusStrEnum):
|
||||||
|
"""Sample StrEnum for testing."""
|
||||||
|
|
||||||
|
ALPHA = "alpha"
|
||||||
|
BETA = "beta"
|
||||||
|
|
||||||
|
|
||||||
|
class SampleIntEnum(DBusIntEnum):
|
||||||
|
"""Sample IntEnum for testing."""
|
||||||
|
|
||||||
|
ONE = 1
|
||||||
|
TWO = 2
|
||||||
|
|
||||||
|
|
||||||
|
# -- DBusStrEnum tests --
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_known_value():
|
||||||
|
"""Test known value returns the defined member."""
|
||||||
|
assert SampleStrEnum("alpha") is SampleStrEnum.ALPHA
|
||||||
|
assert SampleStrEnum("beta") is SampleStrEnum.BETA
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_unknown_value_returns_pseudo_member(caplog):
|
||||||
|
"""Test unknown value creates a pseudo-member."""
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
result = SampleStrEnum("gamma")
|
||||||
|
|
||||||
|
assert isinstance(result, SampleStrEnum)
|
||||||
|
assert result.value == "gamma"
|
||||||
|
assert result.name == "gamma"
|
||||||
|
assert "Unknown SampleStrEnum value received from D-Bus: gamma" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_unknown_value_str():
|
||||||
|
"""Test unknown value behaves as str."""
|
||||||
|
result = SampleStrEnum("gamma")
|
||||||
|
assert str(result) == "gamma"
|
||||||
|
assert result == "gamma"
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_members_not_polluted():
|
||||||
|
"""Test pseudo-members don't appear in __members__ or list()."""
|
||||||
|
SampleStrEnum("gamma")
|
||||||
|
assert "gamma" not in SampleStrEnum.__members__
|
||||||
|
assert set(SampleStrEnum) == {SampleStrEnum.ALPHA, SampleStrEnum.BETA}
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_non_str_raises_value_error():
|
||||||
|
"""Test non-string values raise ValueError."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
SampleStrEnum(123)
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_hash_consistency():
|
||||||
|
"""Test pseudo-members hash like their string value."""
|
||||||
|
result = SampleStrEnum("gamma")
|
||||||
|
assert hash(result) == hash("gamma")
|
||||||
|
assert {result: True}["gamma"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_match_known():
|
||||||
|
"""Test match statement with known value."""
|
||||||
|
val = SampleStrEnum("alpha")
|
||||||
|
match val:
|
||||||
|
case SampleStrEnum.ALPHA:
|
||||||
|
matched = "alpha"
|
||||||
|
case _:
|
||||||
|
matched = "default"
|
||||||
|
assert matched == "alpha"
|
||||||
|
|
||||||
|
|
||||||
|
def test_str_match_unknown_falls_to_default():
|
||||||
|
"""Test match statement with unknown value falls to default."""
|
||||||
|
val = SampleStrEnum("gamma")
|
||||||
|
match val:
|
||||||
|
case SampleStrEnum.ALPHA:
|
||||||
|
matched = "alpha"
|
||||||
|
case SampleStrEnum.BETA:
|
||||||
|
matched = "beta"
|
||||||
|
case _:
|
||||||
|
matched = "default"
|
||||||
|
assert matched == "default"
|
||||||
|
|
||||||
|
|
||||||
|
# -- DBusIntEnum tests --
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_known_value():
|
||||||
|
"""Test known value returns the defined member."""
|
||||||
|
assert SampleIntEnum(1) is SampleIntEnum.ONE
|
||||||
|
assert SampleIntEnum(2) is SampleIntEnum.TWO
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_unknown_value_returns_pseudo_member(caplog):
|
||||||
|
"""Test unknown value creates a pseudo-member."""
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
result = SampleIntEnum(999)
|
||||||
|
|
||||||
|
assert isinstance(result, SampleIntEnum)
|
||||||
|
assert result.value == 999
|
||||||
|
assert result.name == "UNKNOWN_999"
|
||||||
|
assert "Unknown SampleIntEnum value received from D-Bus: 999" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_unknown_value_int():
|
||||||
|
"""Test unknown value behaves as int."""
|
||||||
|
result = SampleIntEnum(999)
|
||||||
|
assert int(result) == 999
|
||||||
|
assert result == 999
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_members_not_polluted():
|
||||||
|
"""Test pseudo-members don't appear in __members__ or list()."""
|
||||||
|
SampleIntEnum(999)
|
||||||
|
assert "UNKNOWN_999" not in SampleIntEnum.__members__
|
||||||
|
assert set(SampleIntEnum) == {SampleIntEnum.ONE, SampleIntEnum.TWO}
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_non_int_raises_value_error():
|
||||||
|
"""Test non-integer values raise ValueError."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
SampleIntEnum("abc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_hash_consistency():
|
||||||
|
"""Test pseudo-members hash like their int value."""
|
||||||
|
result = SampleIntEnum(999)
|
||||||
|
assert hash(result) == hash(999)
|
||||||
|
assert {result: True}[999]
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_match_known():
|
||||||
|
"""Test match statement with known value."""
|
||||||
|
val = SampleIntEnum(1)
|
||||||
|
match val:
|
||||||
|
case SampleIntEnum.ONE:
|
||||||
|
matched = "one"
|
||||||
|
case _:
|
||||||
|
matched = "default"
|
||||||
|
assert matched == "one"
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_match_unknown_falls_to_default():
|
||||||
|
"""Test match statement with unknown value falls to default."""
|
||||||
|
val = SampleIntEnum(999)
|
||||||
|
match val:
|
||||||
|
case SampleIntEnum.ONE:
|
||||||
|
matched = "one"
|
||||||
|
case SampleIntEnum.TWO:
|
||||||
|
matched = "two"
|
||||||
|
case _:
|
||||||
|
matched = "default"
|
||||||
|
assert matched == "default"
|
||||||
|
|
||||||
|
|
||||||
|
# -- Integration tests with actual D-Bus enums --
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_type_unknown():
|
||||||
|
"""Test DeviceType handles unknown device types."""
|
||||||
|
result = DeviceType(999)
|
||||||
|
assert isinstance(result, DeviceType)
|
||||||
|
assert result.value == 999
|
||||||
|
assert result != DeviceType.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_type_known():
|
||||||
|
"""Test DeviceType still works for known values."""
|
||||||
|
assert DeviceType(1) is DeviceType.ETHERNET
|
||||||
|
assert DeviceType(2) is DeviceType.WIRELESS
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_active_state_unknown():
|
||||||
|
"""Test UnitActiveState handles unknown states."""
|
||||||
|
result = UnitActiveState("refreshing")
|
||||||
|
assert isinstance(result, UnitActiveState)
|
||||||
|
assert result.value == "refreshing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_active_state_known():
|
||||||
|
"""Test UnitActiveState still works for known values."""
|
||||||
|
assert UnitActiveState("active") is UnitActiveState.ACTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_rauc_state_unknown():
|
||||||
|
"""Test RaucState handles unknown states."""
|
||||||
|
result = RaucState("testing")
|
||||||
|
assert isinstance(result, RaucState)
|
||||||
|
assert result.value == "testing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_type_unknown():
|
||||||
|
"""Test ConnectionType handles unknown types."""
|
||||||
|
result = ConnectionType("802-11-olpc-mesh")
|
||||||
|
assert isinstance(result, ConnectionType)
|
||||||
|
assert result.value == "802-11-olpc-mesh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_state_unknown():
|
||||||
|
"""Test ConnectionState handles unknown states."""
|
||||||
|
result = ConnectionState(99)
|
||||||
|
assert isinstance(result, ConnectionState)
|
||||||
|
assert result.value == 99
|
||||||
|
|
||||||
|
|
||||||
|
def test_connectivity_state_unknown():
|
||||||
|
"""Test ConnectivityState handles unknown states."""
|
||||||
|
result = ConnectivityState(99)
|
||||||
|
assert isinstance(result, ConnectivityState)
|
||||||
|
assert result.value == 99
|
||||||
|
|
||||||
|
|
||||||
|
def test_wireless_method_type_unknown():
|
||||||
|
"""Test WirelessMethodType handles unknown types."""
|
||||||
|
result = WirelessMethodType(99)
|
||||||
|
assert isinstance(result, WirelessMethodType)
|
||||||
|
assert result.value == 99
|
||||||
|
|
||||||
|
|
||||||
|
def test_interface_method_unknown():
|
||||||
|
"""Test InterfaceMethod handles unknown methods."""
|
||||||
|
result = InterfaceMethod("shared")
|
||||||
|
assert isinstance(result, InterfaceMethod)
|
||||||
|
assert result.value == "shared"
|
||||||
|
|
||||||
|
|
||||||
|
def test_multicast_protocol_enabled_unknown():
|
||||||
|
"""Test MulticastProtocolEnabled handles unknown values."""
|
||||||
|
result = MulticastProtocolEnabled("maybe")
|
||||||
|
assert isinstance(result, MulticastProtocolEnabled)
|
||||||
|
assert result.value == "maybe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dns_over_tls_enabled_unknown():
|
||||||
|
"""Test DNSOverTLSEnabled handles unknown values."""
|
||||||
|
result = DNSOverTLSEnabled("strict")
|
||||||
|
assert isinstance(result, DNSOverTLSEnabled)
|
||||||
|
assert result.value == "strict"
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_table_type_unknown():
|
||||||
|
"""Test PartitionTableType handles unknown types."""
|
||||||
|
result = PartitionTableType("mbr")
|
||||||
|
assert isinstance(result, PartitionTableType)
|
||||||
|
assert result.value == "mbr"
|
||||||
|
|
||||||
|
|
||||||
|
# -- Sentry reporting tests --
|
||||||
|
|
||||||
|
|
||||||
|
@patch("supervisor.dbus.enum.fire_and_forget_capture_message")
|
||||||
|
def test_unknown_str_reports_to_sentry(mock_capture):
|
||||||
|
"""Test unknown StrEnum value is reported to Sentry."""
|
||||||
|
SampleStrEnum("delta")
|
||||||
|
mock_capture.assert_called_once_with(
|
||||||
|
"Unknown SampleStrEnum value received from D-Bus: delta"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("supervisor.dbus.enum.fire_and_forget_capture_message")
|
||||||
|
def test_unknown_int_reports_to_sentry(mock_capture):
|
||||||
|
"""Test unknown IntEnum value is reported to Sentry."""
|
||||||
|
SampleIntEnum(777)
|
||||||
|
mock_capture.assert_called_once_with(
|
||||||
|
"Unknown SampleIntEnum value received from D-Bus: 777"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("supervisor.dbus.enum.fire_and_forget_capture_message")
|
||||||
|
def test_duplicate_not_reported_twice(mock_capture):
|
||||||
|
"""Test the same unknown value is only reported to Sentry once."""
|
||||||
|
SampleIntEnum(888)
|
||||||
|
SampleIntEnum(888)
|
||||||
|
SampleIntEnum(888)
|
||||||
|
mock_capture.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("supervisor.dbus.enum.fire_and_forget_capture_message")
|
||||||
|
def test_different_values_each_reported(mock_capture):
|
||||||
|
"""Test different unknown values are each reported separately."""
|
||||||
|
SampleIntEnum(100)
|
||||||
|
SampleIntEnum(200)
|
||||||
|
assert mock_capture.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@patch("supervisor.dbus.enum.fire_and_forget_capture_message")
|
||||||
|
def test_known_value_not_reported(mock_capture):
|
||||||
|
"""Test known values don't trigger Sentry reports."""
|
||||||
|
SampleStrEnum("alpha")
|
||||||
|
SampleIntEnum(1)
|
||||||
|
mock_capture.assert_not_called()
|
||||||
Reference in New Issue
Block a user