diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py index 9608347c8e7..1901bfdc0e7 100644 --- a/homeassistant/components/cloudflare/__init__.py +++ b/homeassistant/components/cloudflare/__init__.py @@ -1,17 +1,12 @@ """Update the IP addresses of your Cloudflare DNS records.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from aiohttp import ClientSession -from pycfdns import CloudflareUpdater -from pycfdns.exceptions import ( - CloudflareAuthenticationException, - CloudflareConnectionException, - CloudflareException, - CloudflareZoneException, -) +import pycfdns from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, CONF_ZONE @@ -37,32 +32,43 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Cloudflare from a config entry.""" session = async_get_clientsession(hass) - cfupdate = CloudflareUpdater( - session, - entry.data[CONF_API_TOKEN], - entry.data[CONF_ZONE], - entry.data[CONF_RECORDS], + client = pycfdns.Client( + api_token=entry.data[CONF_API_TOKEN], + client_session=session, ) try: - zone_id = await cfupdate.get_zone_id() - except CloudflareAuthenticationException as error: + dns_zones = await client.list_zones() + dns_zone = next( + zone for zone in dns_zones if zone["name"] == entry.data[CONF_ZONE] + ) + except pycfdns.AuthenticationException as error: raise ConfigEntryAuthFailed from error - except (CloudflareConnectionException, CloudflareZoneException) as error: + except pycfdns.ComunicationException as error: raise ConfigEntryNotReady from error async def update_records(now): """Set up recurring update.""" try: - await _async_update_cloudflare(session, cfupdate, zone_id) - except CloudflareException as error: + await _async_update_cloudflare( + session, client, dns_zone, entry.data[CONF_RECORDS] + ) + except ( + pycfdns.AuthenticationException, + pycfdns.ComunicationException, + ) as error: _LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error) async def update_records_service(call: ServiceCall) -> None: """Set up service for manual trigger.""" try: - await _async_update_cloudflare(session, cfupdate, zone_id) - except CloudflareException as error: + await _async_update_cloudflare( + session, client, dns_zone, entry.data[CONF_RECORDS] + ) + except ( + pycfdns.AuthenticationException, + pycfdns.ComunicationException, + ) as error: _LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error) update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL) @@ -87,12 +93,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_cloudflare( session: ClientSession, - cfupdate: CloudflareUpdater, - zone_id: str, + client: pycfdns.Client, + dns_zone: pycfdns.ZoneModel, + target_records: list[str], ) -> None: - _LOGGER.debug("Starting update for zone %s", cfupdate.zone) + _LOGGER.debug("Starting update for zone %s", dns_zone["name"]) - records = await cfupdate.get_record_info(zone_id) + records = await client.list_dns_records(zone_id=dns_zone["id"], type="A") _LOGGER.debug("Records: %s", records) location_info = await async_detect_location_info(session) @@ -100,5 +107,28 @@ async def _async_update_cloudflare( if not location_info or not is_ipv4_address(location_info.ip): raise HomeAssistantError("Could not get external IPv4 address") - await cfupdate.update_records(zone_id, records, location_info.ip) - _LOGGER.debug("Update for zone %s is complete", cfupdate.zone) + filtered_records = [ + record + for record in records + if record["name"] in target_records and record["content"] != location_info.ip + ] + + if len(filtered_records) == 0: + _LOGGER.debug("All target records are up to date") + return + + await asyncio.gather( + *[ + client.update_dns_record( + zone_id=dns_zone["id"], + record_id=record["id"], + record_content=location_info.ip, + record_name=record["name"], + record_type=record["type"], + record_proxied=record["proxied"], + ) + for record in filtered_records + ] + ) + + _LOGGER.debug("Update for zone %s is complete", dns_zone["name"]) diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index 215411bc667..99f6109be4a 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -5,12 +5,7 @@ from collections.abc import Mapping import logging from typing import Any -from pycfdns import CloudflareUpdater -from pycfdns.exceptions import ( - CloudflareAuthenticationException, - CloudflareConnectionException, - CloudflareZoneException, -) +import pycfdns import voluptuous as vol from homeassistant.components import persistent_notification @@ -23,6 +18,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_RECORDS, DOMAIN +from .helpers import get_zone_id _LOGGER = logging.getLogger(__name__) @@ -33,54 +29,45 @@ DATA_SCHEMA = vol.Schema( ) -def _zone_schema(zones: list[str] | None = None) -> vol.Schema: +def _zone_schema(zones: list[pycfdns.ZoneModel] | None = None) -> vol.Schema: """Zone selection schema.""" zones_list = [] if zones is not None: - zones_list = zones + zones_list = [zones["name"] for zones in zones] return vol.Schema({vol.Required(CONF_ZONE): vol.In(zones_list)}) -def _records_schema(records: list[str] | None = None) -> vol.Schema: +def _records_schema(records: list[pycfdns.RecordModel] | None = None) -> vol.Schema: """Zone records selection schema.""" records_dict = {} if records: - records_dict = {name: name for name in records} + records_dict = {name["name"]: name["name"] for name in records} return vol.Schema({vol.Required(CONF_RECORDS): cv.multi_select(records_dict)}) async def _validate_input( - hass: HomeAssistant, data: dict[str, Any] -) -> dict[str, list[str] | None]: + hass: HomeAssistant, + data: dict[str, Any], +) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. """ zone = data.get(CONF_ZONE) - records: list[str] | None = None + records: list[pycfdns.RecordModel] = [] - cfupdate = CloudflareUpdater( - async_get_clientsession(hass), - data[CONF_API_TOKEN], - zone, - [], + client = pycfdns.Client( + api_token=data[CONF_API_TOKEN], + client_session=async_get_clientsession(hass), ) - try: - zones: list[str] | None = await cfupdate.get_zones() - if zone: - zone_id = await cfupdate.get_zone_id() - records = await cfupdate.get_zone_records(zone_id, "A") - except CloudflareConnectionException as error: - raise CannotConnect from error - except CloudflareAuthenticationException as error: - raise InvalidAuth from error - except CloudflareZoneException as error: - raise InvalidZone from error + zones = await client.list_zones() + if zone and (zone_id := get_zone_id(zone, zones)) is not None: + records = await client.list_dns_records(zone_id=zone_id, type="A") return {"zones": zones, "records": records} @@ -95,8 +82,8 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Cloudflare config flow.""" self.cloudflare_config: dict[str, Any] = {} - self.zones: list[str] | None = None - self.records: list[str] | None = None + self.zones: list[pycfdns.ZoneModel] | None = None + self.records: list[pycfdns.RecordModel] | None = None async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Cloudflare.""" @@ -195,18 +182,16 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_validate_or_error( self, config: dict[str, Any] - ) -> tuple[dict[str, list[str] | None], dict[str, str]]: + ) -> tuple[dict[str, list[Any]], dict[str, str]]: errors: dict[str, str] = {} info = {} try: info = await _validate_input(self.hass, config) - except CannotConnect: + except pycfdns.ComunicationException: errors["base"] = "cannot_connect" - except InvalidAuth: + except pycfdns.AuthenticationException: errors["base"] = "invalid_auth" - except InvalidZone: - errors["base"] = "invalid_zone" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -220,7 +205,3 @@ class CannotConnect(HomeAssistantError): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" - - -class InvalidZone(HomeAssistantError): - """Error to indicate we cannot validate zone exists in account.""" diff --git a/homeassistant/components/cloudflare/helpers.py b/homeassistant/components/cloudflare/helpers.py new file mode 100644 index 00000000000..0542bce0980 --- /dev/null +++ b/homeassistant/components/cloudflare/helpers.py @@ -0,0 +1,10 @@ +"""Helpers for the CloudFlare integration.""" +import pycfdns + + +def get_zone_id(target_zone_name: str, zones: list[pycfdns.ZoneModel]) -> str | None: + """Get the zone ID for the target zone name.""" + for zone in zones: + if zone["name"] == target_zone_name: + return zone["id"] + return None diff --git a/homeassistant/components/cloudflare/manifest.json b/homeassistant/components/cloudflare/manifest.json index 8c901de3984..0f689aa3e03 100644 --- a/homeassistant/components/cloudflare/manifest.json +++ b/homeassistant/components/cloudflare/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/cloudflare", "iot_class": "cloud_push", "loggers": ["pycfdns"], - "requirements": ["pycfdns==2.0.1"] + "requirements": ["pycfdns==3.0.0"] } diff --git a/homeassistant/components/cloudflare/strings.json b/homeassistant/components/cloudflare/strings.json index 080be414b5c..75dc8f079c7 100644 --- a/homeassistant/components/cloudflare/strings.json +++ b/homeassistant/components/cloudflare/strings.json @@ -30,8 +30,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_zone": "Invalid zone" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", diff --git a/requirements_all.txt b/requirements_all.txt index 384b86fece4..a9795a14fa1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1630,7 +1630,7 @@ pybravia==0.3.3 pycarwings2==2.14 # homeassistant.components.cloudflare -pycfdns==2.0.1 +pycfdns==3.0.0 # homeassistant.components.channels pychannels==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21a9f6f1773..9165494b7fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1242,7 +1242,7 @@ pybotvac==0.0.24 pybravia==0.3.3 # homeassistant.components.cloudflare -pycfdns==2.0.1 +pycfdns==3.0.0 # homeassistant.components.comfoconnect pycomfoconnect==0.5.1 diff --git a/tests/components/cloudflare/__init__.py b/tests/components/cloudflare/__init__.py index f2eaccab470..8ba8b23b65f 100644 --- a/tests/components/cloudflare/__init__.py +++ b/tests/components/cloudflare/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -from pycfdns import CFRecord +import pycfdns from homeassistant.components.cloudflare.const import CONF_RECORDS, DOMAIN from homeassistant.const import CONF_API_TOKEN, CONF_ZONE @@ -26,9 +26,8 @@ USER_INPUT_ZONE = {CONF_ZONE: "mock.com"} USER_INPUT_RECORDS = {CONF_RECORDS: ["ha.mock.com", "homeassistant.mock.com"]} -MOCK_ZONE = "mock.com" -MOCK_ZONE_ID = "mock-zone-id" -MOCK_ZONE_RECORDS = [ +MOCK_ZONE: pycfdns.ZoneModel = {"name": "mock.com", "id": "mock-zone-id"} +MOCK_ZONE_RECORDS: list[pycfdns.RecordModel] = [ { "id": "zone-record-id", "type": "A", @@ -77,21 +76,12 @@ async def init_integration( return entry -def _get_mock_cfupdate( - zone: str = MOCK_ZONE, - zone_id: str = MOCK_ZONE_ID, - records: list = MOCK_ZONE_RECORDS, -): - client = AsyncMock() +def _get_mock_client(zone: str = MOCK_ZONE, records: list = MOCK_ZONE_RECORDS): + client: pycfdns.Client = AsyncMock() - zone_records = [record["name"] for record in records] - cf_records = [CFRecord(record) for record in records] - - client.get_zones = AsyncMock(return_value=[zone]) - client.get_zone_records = AsyncMock(return_value=zone_records) - client.get_record_info = AsyncMock(return_value=cf_records) - client.get_zone_id = AsyncMock(return_value=zone_id) - client.update_records = AsyncMock(return_value=None) + client.list_zones = AsyncMock(return_value=[zone]) + client.list_dns_records = AsyncMock(return_value=records) + client.update_dns_record = AsyncMock(return_value=None) return client diff --git a/tests/components/cloudflare/conftest.py b/tests/components/cloudflare/conftest.py index 0d9ac040c8e..de0e1a85b77 100644 --- a/tests/components/cloudflare/conftest.py +++ b/tests/components/cloudflare/conftest.py @@ -3,15 +3,15 @@ from unittest.mock import patch import pytest -from . import _get_mock_cfupdate +from . import _get_mock_client @pytest.fixture def cfupdate(hass): """Mock the CloudflareUpdater for easier testing.""" - mock_cfupdate = _get_mock_cfupdate() + mock_cfupdate = _get_mock_client() with patch( - "homeassistant.components.cloudflare.CloudflareUpdater", + "homeassistant.components.cloudflare.pycfdns.Client", return_value=mock_cfupdate, ) as mock_api: yield mock_api @@ -20,9 +20,9 @@ def cfupdate(hass): @pytest.fixture def cfupdate_flow(hass): """Mock the CloudflareUpdater for easier config flow testing.""" - mock_cfupdate = _get_mock_cfupdate() + mock_cfupdate = _get_mock_client() with patch( - "homeassistant.components.cloudflare.config_flow.CloudflareUpdater", + "homeassistant.components.cloudflare.pycfdns.Client", return_value=mock_cfupdate, ) as mock_api: yield mock_api diff --git a/tests/components/cloudflare/test_config_flow.py b/tests/components/cloudflare/test_config_flow.py index c0373866580..21ee364eca3 100644 --- a/tests/components/cloudflare/test_config_flow.py +++ b/tests/components/cloudflare/test_config_flow.py @@ -1,9 +1,5 @@ """Test the Cloudflare config flow.""" -from pycfdns.exceptions import ( - CloudflareAuthenticationException, - CloudflareConnectionException, - CloudflareZoneException, -) +import pycfdns from homeassistant.components.cloudflare.const import CONF_RECORDS, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER @@ -81,7 +77,7 @@ async def test_user_form_cannot_connect(hass: HomeAssistant, cfupdate_flow) -> N DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - instance.get_zones.side_effect = CloudflareConnectionException() + instance.list_zones.side_effect = pycfdns.ComunicationException() result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -99,7 +95,7 @@ async def test_user_form_invalid_auth(hass: HomeAssistant, cfupdate_flow) -> Non DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - instance.get_zones.side_effect = CloudflareAuthenticationException() + instance.list_zones.side_effect = pycfdns.AuthenticationException() result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -109,24 +105,6 @@ async def test_user_form_invalid_auth(hass: HomeAssistant, cfupdate_flow) -> Non assert result["errors"] == {"base": "invalid_auth"} -async def test_user_form_invalid_zone(hass: HomeAssistant, cfupdate_flow) -> None: - """Test we handle invalid zone error.""" - instance = cfupdate_flow.return_value - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_USER} - ) - - instance.get_zones.side_effect = CloudflareZoneException() - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_zone"} - - async def test_user_form_unexpected_exception( hass: HomeAssistant, cfupdate_flow ) -> None: @@ -137,7 +115,7 @@ async def test_user_form_unexpected_exception( DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - instance.get_zones.side_effect = Exception() + instance.list_zones.side_effect = Exception() result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, diff --git a/tests/components/cloudflare/test_helpers.py b/tests/components/cloudflare/test_helpers.py new file mode 100644 index 00000000000..74bf8420f8a --- /dev/null +++ b/tests/components/cloudflare/test_helpers.py @@ -0,0 +1,13 @@ +"""Test Cloudflare integration helpers.""" +from homeassistant.components.cloudflare.helpers import get_zone_id + + +def test_get_zone_id(): + """Test get_zone_id.""" + zones = [ + {"id": "1", "name": "example.com"}, + {"id": "2", "name": "example.org"}, + ] + assert get_zone_id("example.com", zones) == "1" + assert get_zone_id("example.org", zones) == "2" + assert get_zone_id("example.net", zones) is None diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index 9d46a428042..d1c9cb3c352 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -1,22 +1,25 @@ """Test the Cloudflare integration.""" +from datetime import timedelta from unittest.mock import patch -from pycfdns.exceptions import ( - CloudflareAuthenticationException, - CloudflareConnectionException, - CloudflareZoneException, -) +import pycfdns import pytest -from homeassistant.components.cloudflare.const import DOMAIN, SERVICE_UPDATE_RECORDS +from homeassistant.components.cloudflare.const import ( + CONF_RECORDS, + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + SERVICE_UPDATE_RECORDS, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +import homeassistant.util.dt as dt_util from homeassistant.util.location import LocationInfo from . import ENTRY_CONFIG, init_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_unload_entry(hass: HomeAssistant, cfupdate) -> None: @@ -35,10 +38,7 @@ async def test_unload_entry(hass: HomeAssistant, cfupdate) -> None: @pytest.mark.parametrize( "side_effect", - ( - CloudflareConnectionException(), - CloudflareZoneException(), - ), + (pycfdns.ComunicationException(),), ) async def test_async_setup_raises_entry_not_ready( hass: HomeAssistant, cfupdate, side_effect @@ -49,7 +49,7 @@ async def test_async_setup_raises_entry_not_ready( entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG) entry.add_to_hass(hass) - instance.get_zone_id.side_effect = side_effect + instance.list_zones.side_effect = side_effect await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -64,7 +64,7 @@ async def test_async_setup_raises_entry_auth_failed( entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG) entry.add_to_hass(hass) - instance.get_zone_id.side_effect = CloudflareAuthenticationException() + instance.list_zones.side_effect = pycfdns.AuthenticationException() await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_ERROR @@ -81,7 +81,7 @@ async def test_async_setup_raises_entry_auth_failed( assert flow["context"]["entry_id"] == entry.entry_id -async def test_integration_services(hass: HomeAssistant, cfupdate) -> None: +async def test_integration_services(hass: HomeAssistant, cfupdate, caplog) -> None: """Test integration services.""" instance = cfupdate.return_value @@ -112,7 +112,8 @@ async def test_integration_services(hass: HomeAssistant, cfupdate) -> None: ) await hass.async_block_till_done() - instance.update_records.assert_called_once() + assert len(instance.update_dns_record.mock_calls) == 2 + assert "All target records are up to date" not in caplog.text async def test_integration_services_with_issue(hass: HomeAssistant, cfupdate) -> None: @@ -134,4 +135,92 @@ async def test_integration_services_with_issue(hass: HomeAssistant, cfupdate) -> ) await hass.async_block_till_done() - instance.update_records.assert_not_called() + instance.update_dns_record.assert_not_called() + + +async def test_integration_services_with_nonexisting_record( + hass: HomeAssistant, cfupdate, caplog +) -> None: + """Test integration services.""" + instance = cfupdate.return_value + + entry = await init_integration( + hass, data={**ENTRY_CONFIG, CONF_RECORDS: ["nonexisting.example.com"]} + ) + assert entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.components.cloudflare.async_detect_location_info", + return_value=LocationInfo( + "0.0.0.0", + "US", + "USD", + "CA", + "California", + "San Diego", + "92122", + "America/Los_Angeles", + 32.8594, + -117.2073, + True, + ), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_RECORDS, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + instance.update_dns_record.assert_not_called() + assert "All target records are up to date" in caplog.text + + +async def test_integration_update_interval( + hass: HomeAssistant, + cfupdate, + caplog, +) -> None: + """Test integration update interval.""" + instance = cfupdate.return_value + + entry = await init_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.components.cloudflare.async_detect_location_info", + return_value=LocationInfo( + "0.0.0.0", + "US", + "USD", + "CA", + "California", + "San Diego", + "92122", + "America/Los_Angeles", + 32.8594, + -117.2073, + True, + ), + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + assert len(instance.update_dns_record.mock_calls) == 2 + assert "All target records are up to date" not in caplog.text + + instance.list_dns_records.side_effect = pycfdns.AuthenticationException() + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + assert len(instance.update_dns_record.mock_calls) == 2 + + instance.list_dns_records.side_effect = pycfdns.ComunicationException() + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + ) + await hass.async_block_till_done() + assert len(instance.update_dns_record.mock_calls) == 2