1
0
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:
Stefan Agner
2026-02-11 15:53:19 +01:00
committed by GitHub
parent 4b9f62b14b
commit 6877a8b210
8 changed files with 439 additions and 34 deletions

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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.

View File

@@ -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
View 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()