From 174ebe70d74052029324addcce5df4051fa2c7a0 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Mon, 26 Feb 2024 17:10:11 +0100 Subject: [PATCH] Add webmin integration (#106976) * add webmin integration 1 * refactor, add memory sensors * Fix docstring * addressed reviews * address reviews * address reviews * use translation strings for sensors * add async_abort_entries_match * apply review comments * address reviews * add async_set_unique_id * add identifiers to device_info * disable all sensors by default * move icons to icons.json * show Faults when given from server in config flow * add test for Fault * Apply review suggestions * Create helper functions for webmin instance and sorted mac addresses * fix tests --- .coveragerc | 1 + CODEOWNERS | 2 + homeassistant/components/webmin/__init__.py | 30 ++++ .../components/webmin/config_flow.py | 95 ++++++++++++ homeassistant/components/webmin/const.py | 10 ++ .../components/webmin/coordinator.py | 53 +++++++ homeassistant/components/webmin/helpers.py | 47 ++++++ homeassistant/components/webmin/icons.json | 27 ++++ homeassistant/components/webmin/manifest.json | 11 ++ homeassistant/components/webmin/sensor.py | 112 ++++++++++++++ homeassistant/components/webmin/strings.json | 54 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/webmin/__init__.py | 1 + tests/components/webmin/conftest.py | 33 +++++ .../fixtures/webmin_network_interfaces.json | 45 ++++++ .../webmin/fixtures/webmin_update.json | 97 ++++++++++++ tests/components/webmin/test_config_flow.py | 140 ++++++++++++++++++ tests/components/webmin/test_init.py | 32 ++++ 21 files changed, 803 insertions(+) create mode 100644 homeassistant/components/webmin/__init__.py create mode 100644 homeassistant/components/webmin/config_flow.py create mode 100644 homeassistant/components/webmin/const.py create mode 100644 homeassistant/components/webmin/coordinator.py create mode 100644 homeassistant/components/webmin/helpers.py create mode 100644 homeassistant/components/webmin/icons.json create mode 100644 homeassistant/components/webmin/manifest.json create mode 100644 homeassistant/components/webmin/sensor.py create mode 100644 homeassistant/components/webmin/strings.json create mode 100644 tests/components/webmin/__init__.py create mode 100644 tests/components/webmin/conftest.py create mode 100644 tests/components/webmin/fixtures/webmin_network_interfaces.json create mode 100644 tests/components/webmin/fixtures/webmin_update.json create mode 100644 tests/components/webmin/test_config_flow.py create mode 100644 tests/components/webmin/test_init.py diff --git a/.coveragerc b/.coveragerc index c882215e294..a7faeb7cd95 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1588,6 +1588,7 @@ omit = homeassistant/components/weatherflow/__init__.py homeassistant/components/weatherflow/const.py homeassistant/components/weatherflow/sensor.py + homeassistant/components/webmin/sensor.py homeassistant/components/wiffi/__init__.py homeassistant/components/wiffi/binary_sensor.py homeassistant/components/wiffi/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 6c278651ed1..1e34605e6c9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1515,6 +1515,8 @@ build.json @home-assistant/supervisor /tests/components/weatherkit/ @tjhorner /homeassistant/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core +/homeassistant/components/webmin/ @autinerd +/tests/components/webmin/ @autinerd /homeassistant/components/webostv/ @thecode /tests/components/webostv/ @thecode /homeassistant/components/websocket_api/ @home-assistant/core diff --git a/homeassistant/components/webmin/__init__.py b/homeassistant/components/webmin/__init__.py new file mode 100644 index 00000000000..56f30d3b26f --- /dev/null +++ b/homeassistant/components/webmin/__init__.py @@ -0,0 +1,30 @@ +"""The Webmin integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import WebminUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Webmin from a config entry.""" + + coordinator = WebminUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + await coordinator.async_setup() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/webmin/config_flow.py b/homeassistant/components/webmin/config_flow.py new file mode 100644 index 00000000000..783590d35ba --- /dev/null +++ b/homeassistant/components/webmin/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow for Webmin.""" +from __future__ import annotations + +from collections.abc import Mapping +from http import HTTPStatus +from typing import Any, cast +from xmlrpc.client import Fault + +from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.helpers import selector +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowError, + SchemaFlowFormStep, +) + +from .const import DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN +from .helpers import get_instance_from_options, get_sorted_mac_addresses + + +async def validate_user_input( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate user input.""" + # pylint: disable-next=protected-access + handler.parent_handler._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST]} + ) + instance, _ = get_instance_from_options(handler.parent_handler.hass, user_input) + try: + data = await instance.update() + except ClientResponseError as err: + if err.status == HTTPStatus.UNAUTHORIZED: + raise SchemaFlowError("invalid_auth") from err + raise SchemaFlowError("cannot_connect") from err + except Fault as fault: + raise SchemaFlowError( + f"Fault {fault.faultCode}: {fault.faultString}" + ) from fault + except ClientConnectionError as err: + raise SchemaFlowError("cannot_connect") from err + except Exception as err: + raise SchemaFlowError("unknown") from err + + await cast(SchemaConfigFlowHandler, handler.parent_handler).async_set_unique_id( + get_sorted_mac_addresses(data)[0] + ) + return user_input + + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): selector.TextSelector(), + vol.Required(CONF_PORT, default=DEFAULT_PORT): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=65535, mode=selector.NumberSelectorMode.BOX + ) + ), + vol.Required(CONF_USERNAME): selector.TextSelector(), + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required(CONF_SSL, default=DEFAULT_SSL): selector.BooleanSelector(), + vol.Required( + CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL + ): selector.BooleanSelector(), + } +) + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=CONFIG_SCHEMA, validate_user_input=validate_user_input + ), +} + + +class WebminConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Webmin.""" + + config_flow = CONFIG_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return str(options[CONF_HOST]) diff --git a/homeassistant/components/webmin/const.py b/homeassistant/components/webmin/const.py new file mode 100644 index 00000000000..8bfadefedaa --- /dev/null +++ b/homeassistant/components/webmin/const.py @@ -0,0 +1,10 @@ +"""Constants for the Webmin integration.""" + +from logging import Logger, getLogger + +LOGGER: Logger = getLogger(__package__) +DOMAIN = "webmin" + +DEFAULT_PORT = 10000 +DEFAULT_SSL = True +DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/webmin/coordinator.py b/homeassistant/components/webmin/coordinator.py new file mode 100644 index 00000000000..9a725ee2a77 --- /dev/null +++ b/homeassistant/components/webmin/coordinator.py @@ -0,0 +1,53 @@ +"""Data update coordinator for the Webmin integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER +from .helpers import get_instance_from_options, get_sorted_mac_addresses + + +class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """The Webmin data update coordinator.""" + + mac_address: str + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the Webmin data update coordinator.""" + + super().__init__( + hass, logger=LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + ) + + self.instance, base_url = get_instance_from_options(hass, config_entry.options) + + self.device_info = DeviceInfo( + configuration_url=base_url, + name=config_entry.options[CONF_HOST], + ) + + async def async_setup(self) -> None: + """Provide needed data to the device info.""" + mac_addresses = get_sorted_mac_addresses(self.data) + self.mac_address = mac_addresses[0] + self.device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, format_mac(mac_address)) + for mac_address in mac_addresses + } + self.device_info[ATTR_IDENTIFIERS] = { + (DOMAIN, format_mac(mac_address)) for mac_address in mac_addresses + } + + async def _async_update_data(self) -> dict[str, Any]: + return await self.instance.update() diff --git a/homeassistant/components/webmin/helpers.py b/homeassistant/components/webmin/helpers.py new file mode 100644 index 00000000000..6d290183e76 --- /dev/null +++ b/homeassistant/components/webmin/helpers.py @@ -0,0 +1,47 @@ +"""Helper functions for the Webmin integration.""" + +from collections.abc import Mapping +from typing import Any + +from webmin_xmlrpc.client import WebminInstance +from yarl import URL + +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_create_clientsession + + +def get_instance_from_options( + hass: HomeAssistant, options: Mapping[str, Any] +) -> tuple[WebminInstance, URL]: + """Retrieve a Webmin instance and the base URL from config options.""" + + base_url = URL.build( + scheme="https" if options[CONF_SSL] else "http", + user=options[CONF_USERNAME], + password=options[CONF_PASSWORD], + host=options[CONF_HOST], + port=int(options[CONF_PORT]), + ) + + return WebminInstance( + session=async_create_clientsession( + hass, + verify_ssl=options[CONF_VERIFY_SSL], + base_url=base_url, + ) + ), base_url + + +def get_sorted_mac_addresses(data: dict[str, Any]) -> list[str]: + """Return a sorted list of mac addresses.""" + return sorted( + [iface["ether"] for iface in data["active_interfaces"] if "ether" in iface] + ) diff --git a/homeassistant/components/webmin/icons.json b/homeassistant/components/webmin/icons.json new file mode 100644 index 00000000000..2421974024a --- /dev/null +++ b/homeassistant/components/webmin/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "load_1m": { + "default": "mdi:chip" + }, + "load_5m": { + "default": "mdi:chip" + }, + "load_15m": { + "default": "mdi:chip" + }, + "mem_total": { + "default": "mdi:memory" + }, + "mem_free": { + "default": "mdi:memory" + }, + "swap_total": { + "default": "mdi:memory" + }, + "swap_free": { + "default": "mdi:memory" + } + } + } +} diff --git a/homeassistant/components/webmin/manifest.json b/homeassistant/components/webmin/manifest.json new file mode 100644 index 00000000000..a15ca0a1f0d --- /dev/null +++ b/homeassistant/components/webmin/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "webmin", + "name": "Webmin", + "codeowners": ["@autinerd"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/webmin", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["webmin"], + "requirements": ["webmin-xmlrpc==0.0.1"] +} diff --git a/homeassistant/components/webmin/sensor.py b/homeassistant/components/webmin/sensor.py new file mode 100644 index 00000000000..f20f8f9b625 --- /dev/null +++ b/homeassistant/components/webmin/sensor.py @@ -0,0 +1,112 @@ +"""Support for Webmin sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import WebminUpdateCoordinator + +SENSOR_TYPES: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="load_1m", + translation_key="load_1m", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="load_5m", + translation_key="load_5m", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="load_15m", + translation_key="load_15m", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="mem_total", + translation_key="mem_total", + native_unit_of_measurement=UnitOfInformation.KIBIBYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="mem_free", + translation_key="mem_free", + native_unit_of_measurement=UnitOfInformation.KIBIBYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="swap_total", + translation_key="swap_total", + native_unit_of_measurement=UnitOfInformation.KIBIBYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="swap_free", + translation_key="swap_free", + native_unit_of_measurement=UnitOfInformation.KIBIBYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Webmin sensors based on a config entry.""" + coordinator: WebminUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + WebminSensor(coordinator, description) + for description in SENSOR_TYPES + if description.key in coordinator.data + ) + + +class WebminSensor(CoordinatorEntity[WebminUpdateCoordinator], SensorEntity): + """Represents a Webmin sensor.""" + + entity_description: SensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, coordinator: WebminUpdateCoordinator, description: SensorEntityDescription + ) -> None: + """Initialize a Webmin sensor.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.mac_address}_{description.key}" + + @property + def native_value(self) -> int | float: + """Return the state of the sensor.""" + return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/webmin/strings.json b/homeassistant/components/webmin/strings.json new file mode 100644 index 00000000000..9963298d230 --- /dev/null +++ b/homeassistant/components/webmin/strings.json @@ -0,0 +1,54 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Please enter the connection details of your instance.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "sensor": { + "load_1m": { + "name": "Load (1m)" + }, + "load_5m": { + "name": "Load (5m)" + }, + "load_15m": { + "name": "Load (15m)" + }, + "mem_total": { + "name": "Memory total" + }, + "mem_free": { + "name": "Memory free" + }, + "swap_total": { + "name": "Swap total" + }, + "swap_free": { + "name": "Swap free" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e485bd8dde9..ee7948961b2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -587,6 +587,7 @@ FLOWS = { "waze_travel_time", "weatherflow", "weatherkit", + "webmin", "webostv", "wemo", "whirlpool", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 393df325943..a5b4f986ea4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6668,6 +6668,12 @@ "integration_type": "hub", "config_flow": false }, + "webmin": { + "name": "Webmin", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "wemo": { "name": "Belkin WeMo", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 209b6a44837..80b770146e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2838,6 +2838,9 @@ watchdog==2.3.1 # homeassistant.components.waterfurnace waterfurnace==1.1.0 +# homeassistant.components.webmin +webmin-xmlrpc==0.0.1 + # homeassistant.components.assist_pipeline webrtc-noise-gain==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74c229d14ef..d1f73493b6e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2173,6 +2173,9 @@ wallbox==0.6.0 # homeassistant.components.folder_watcher watchdog==2.3.1 +# homeassistant.components.webmin +webmin-xmlrpc==0.0.1 + # homeassistant.components.assist_pipeline webrtc-noise-gain==1.2.3 diff --git a/tests/components/webmin/__init__.py b/tests/components/webmin/__init__.py new file mode 100644 index 00000000000..0b0d5ef65b6 --- /dev/null +++ b/tests/components/webmin/__init__.py @@ -0,0 +1 @@ +"""Tests for the Webmin integration.""" diff --git a/tests/components/webmin/conftest.py b/tests/components/webmin/conftest.py new file mode 100644 index 00000000000..196ce40408d --- /dev/null +++ b/tests/components/webmin/conftest.py @@ -0,0 +1,33 @@ +"""Fixtures for Webmin integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.webmin.const import DEFAULT_PORT +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +TEST_USER_INPUT = { + CONF_HOST: "192.168.1.1", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: True, + CONF_VERIFY_SSL: False, +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.webmin.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/webmin/fixtures/webmin_network_interfaces.json b/tests/components/webmin/fixtures/webmin_network_interfaces.json new file mode 100644 index 00000000000..13c961aad53 --- /dev/null +++ b/tests/components/webmin/fixtures/webmin_network_interfaces.json @@ -0,0 +1,45 @@ +{ + "active_interfaces": [ + { + "fullname": "lo", + "up": 1, + "index": 0, + "scope6": ["host"], + "netmask": "255.0.0.0", + "netmask6": [128], + "edit": 1, + "broadcast": 0, + "mtu": 65536, + "name": "lo", + "address": "127.0.0.1", + "address6": ["::1"] + }, + { + "mtu": 1500, + "fullname": "enp6s0", + "up": 1, + "index": 1, + "ether": "12:34:56:78:9a:bc", + "address6": [], + "netmask6": [], + "edit": 1, + "scope6": [], + "name": "enp6s0" + }, + { + "edit": 1, + "netmask6": [64], + "netmask": "255.255.255.0", + "scope6": ["link"], + "up": 1, + "index": 2, + "fullname": "eno1", + "address6": ["fe80::2:3:4"], + "address": "192.168.1.4", + "name": "eno1", + "mtu": 1500, + "broadcast": "192.168.1.255", + "ether": "12:34:56:78:9a:bd" + } + ] +} diff --git a/tests/components/webmin/fixtures/webmin_update.json b/tests/components/webmin/fixtures/webmin_update.json new file mode 100644 index 00000000000..c74346a925f --- /dev/null +++ b/tests/components/webmin/fixtures/webmin_update.json @@ -0,0 +1,97 @@ +{ + "load_1m": 0.98, + "load_5m": 1.02, + "load_15m": 1.0, + "mem_total": 32767008, + "mem_free": 26162544, + "swap_total": 1953088, + "swap_free": 1953088, + "total_space": 18104905818112, + "free_space": 8641328926720, + "fs": [ + { + "free": 174511820800, + "dir": "/", + "iused": 391146, + "used": 61225123840, + "type": "ext4", + "device": "UUID=00000000-80b6-0000-8a06-000000000000", + "iused_percent": 3, + "used_percent": 26, + "total": 248431161344, + "itotal": 15482880, + "ifree": 15091734 + }, + { + "iused": 8877, + "used": 4608079593472, + "type": "ext4", + "dir": "/media/disk1", + "free": 1044483624960, + "used_percent": 82, + "ifree": 183131475, + "itotal": 183140352, + "total": 5952635744256, + "device": "UUID=00000000-2bb2-0000-896c-000000000000", + "iused_percent": 1 + }, + { + "used": 3881508986880, + "type": "ext4", + "iused": 3411401, + "dir": "/media/disk2", + "free": 7422333480960, + "used_percent": 35, + "total": 11903838912512, + "itotal": 366198784, + "ifree": 362787383, + "device": "/dev/md127", + "iused_percent": 1 + } + ], + "used_space": 8550813704192, + "uptime": { "days": 3, "minutes": 23, "seconds": 12 }, + "active_interfaces": [ + { + "fullname": "lo", + "up": 1, + "index": 0, + "scope6": ["host"], + "netmask": "255.0.0.0", + "netmask6": [128], + "edit": 1, + "broadcast": 0, + "mtu": 65536, + "name": "lo", + "address": "127.0.0.1", + "address6": ["::1"] + }, + { + "mtu": 1500, + "fullname": "enp6s0", + "up": 1, + "index": 1, + "ether": "12:34:56:78:9a:bc", + "address6": [], + "netmask6": [], + "edit": 1, + "scope6": [], + "name": "enp6s0" + }, + { + "edit": 1, + "netmask6": [64], + "netmask": "255.255.255.0", + "scope6": ["link"], + "up": 1, + "index": 2, + "fullname": "eno1", + "address6": ["fe80::2:3:4"], + "address": "192.168.1.4", + "name": "eno1", + "mtu": 1500, + "broadcast": "192.168.1.255", + "ether": "12:34:56:78:9a:bd" + } + ] +} diff --git a/tests/components/webmin/test_config_flow.py b/tests/components/webmin/test_config_flow.py new file mode 100644 index 00000000000..d61ed5a03d6 --- /dev/null +++ b/tests/components/webmin/test_config_flow.py @@ -0,0 +1,140 @@ +"""Test the Webmin config flow.""" +from __future__ import annotations + +from http import HTTPStatus +from unittest.mock import AsyncMock, patch +from xmlrpc.client import Fault + +from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError +import pytest + +from homeassistant import config_entries +from homeassistant.components.webmin.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_USER_INPUT + +from tests.common import load_json_object_fixture + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.fixture +async def user_flow(hass: HomeAssistant) -> str: + """Return a user-initiated flow after filling in host info.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + return result["flow_id"] + + +async def test_form_user( + hass: HomeAssistant, + user_flow: str, + mock_setup_entry: AsyncMock, +) -> None: + """Test a successful user initiated flow.""" + with patch( + "homeassistant.components.webmin.helpers.WebminInstance.update", + return_value=load_json_object_fixture("webmin_update.json", DOMAIN), + ): + result = await hass.config_entries.flow.async_configure( + user_flow, TEST_USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_USER_INPUT[CONF_HOST] + assert result["options"] == TEST_USER_INPUT + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_type"), + [ + ( + ClientResponseError( + request_info=None, history=None, status=HTTPStatus.UNAUTHORIZED + ), + "invalid_auth", + ), + ( + ClientResponseError( + request_info=None, history=None, status=HTTPStatus.BAD_REQUEST + ), + "cannot_connect", + ), + (ClientConnectionError, "cannot_connect"), + (Exception, "unknown"), + ( + Fault("5", "Webmin module net does not exist"), + "Fault 5: Webmin module net does not exist", + ), + ], +) +async def test_form_user_errors( + hass: HomeAssistant, user_flow: str, exception: Exception, error_type: str +) -> None: + """Test we handle errors.""" + with patch( + "homeassistant.components.webmin.helpers.WebminInstance.update", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + user_flow, TEST_USER_INPUT + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error_type} + + with patch( + "homeassistant.components.webmin.helpers.WebminInstance.update", + return_value=load_json_object_fixture("webmin_update.json", DOMAIN), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_USER_INPUT[CONF_HOST] + assert result["options"] == TEST_USER_INPUT + + +async def test_duplicate_entry( + hass: HomeAssistant, + user_flow: str, + mock_setup_entry: AsyncMock, +) -> None: + """Test a successful user initiated flow.""" + with patch( + "homeassistant.components.webmin.helpers.WebminInstance.update", + return_value=load_json_object_fixture("webmin_update.json", DOMAIN), + ): + result = await hass.config_entries.flow.async_configure( + user_flow, TEST_USER_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_USER_INPUT[CONF_HOST] + assert result["options"] == TEST_USER_INPUT + + with patch( + "homeassistant.components.webmin.helpers.WebminInstance.update", + return_value=load_json_object_fixture("webmin_update.json", DOMAIN), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/webmin/test_init.py b/tests/components/webmin/test_init.py new file mode 100644 index 00000000000..21963a2120c --- /dev/null +++ b/tests/components/webmin/test_init.py @@ -0,0 +1,32 @@ +"""Tests for the Webmin integration.""" +from unittest.mock import patch + +from homeassistant.components.webmin.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import TEST_USER_INPUT + +from tests.common import MockConfigEntry, load_json_object_fixture + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test successful unload of entry.""" + + entry = MockConfigEntry(domain=DOMAIN, options=TEST_USER_INPUT, title="name") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.webmin.helpers.WebminInstance.update", + return_value=load_json_object_fixture("webmin_update.json", DOMAIN), + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN)