Add config flow for cloudflare (#41167)
* add config flow for cloudflare * Create const.py * work on flow. * remove const. * lint. * Apply suggestions from code review Co-authored-by: J. Nick Koston <nick@koston.org> * Update config_flows.py * Update homeassistant/components/cloudflare/strings.json * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review * Update strings.json * Apply suggestions from code review * Update __init__.py * Update __init__.py Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
e203896638
commit
d8577a1550
14 changed files with 692 additions and 49 deletions
|
@ -131,7 +131,6 @@ omit =
|
||||||
homeassistant/components/clickatell/notify.py
|
homeassistant/components/clickatell/notify.py
|
||||||
homeassistant/components/clicksend/notify.py
|
homeassistant/components/clicksend/notify.py
|
||||||
homeassistant/components/clicksend_tts/notify.py
|
homeassistant/components/clicksend_tts/notify.py
|
||||||
homeassistant/components/cloudflare/*
|
|
||||||
homeassistant/components/cmus/media_player.py
|
homeassistant/components/cmus/media_player.py
|
||||||
homeassistant/components/co2signal/*
|
homeassistant/components/co2signal/*
|
||||||
homeassistant/components/coinbase/*
|
homeassistant/components/coinbase/*
|
||||||
|
|
|
@ -75,7 +75,7 @@ homeassistant/components/cisco_ios/* @fbradyirl
|
||||||
homeassistant/components/cisco_mobility_express/* @fbradyirl
|
homeassistant/components/cisco_mobility_express/* @fbradyirl
|
||||||
homeassistant/components/cisco_webex_teams/* @fbradyirl
|
homeassistant/components/cisco_webex_teams/* @fbradyirl
|
||||||
homeassistant/components/cloud/* @home-assistant/cloud
|
homeassistant/components/cloud/* @home-assistant/cloud
|
||||||
homeassistant/components/cloudflare/* @ludeeus
|
homeassistant/components/cloudflare/* @ludeeus @ctalkington
|
||||||
homeassistant/components/comfoconnect/* @michaelarnauts
|
homeassistant/components/comfoconnect/* @michaelarnauts
|
||||||
homeassistant/components/config/* @home-assistant/core
|
homeassistant/components/config/* @home-assistant/core
|
||||||
homeassistant/components/configurator/* @home-assistant/core
|
homeassistant/components/configurator/* @home-assistant/core
|
||||||
|
|
|
@ -1,74 +1,130 @@
|
||||||
"""Update the IP addresses of your Cloudflare DNS records."""
|
"""Update the IP addresses of your Cloudflare DNS records."""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
from pycfdns import CloudflareUpdater
|
from pycfdns import CloudflareUpdater
|
||||||
|
from pycfdns.exceptions import (
|
||||||
|
CloudflareAuthenticationException,
|
||||||
|
CloudflareConnectionException,
|
||||||
|
CloudflareException,
|
||||||
|
)
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.const import CONF_API_KEY, CONF_EMAIL, CONF_ZONE
|
from homeassistant.components import persistent_notification
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_EMAIL, CONF_ZONE
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
from homeassistant.helpers.event import track_time_interval
|
from homeassistant.helpers.event import async_track_time_interval
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_RECORDS,
|
||||||
|
DATA_UNDO_UPDATE_INTERVAL,
|
||||||
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_UPDATE_RECORDS,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
CONF_RECORDS = "records"
|
|
||||||
|
|
||||||
DOMAIN = "cloudflare"
|
|
||||||
|
|
||||||
INTERVAL = timedelta(minutes=60)
|
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
DOMAIN: vol.Schema(
|
DOMAIN: vol.All(
|
||||||
{
|
cv.deprecated(CONF_EMAIL, invalidation_version="0.119"),
|
||||||
vol.Required(CONF_EMAIL): cv.string,
|
cv.deprecated(CONF_API_KEY, invalidation_version="0.119"),
|
||||||
vol.Required(CONF_API_KEY): cv.string,
|
cv.deprecated(CONF_ZONE, invalidation_version="0.119"),
|
||||||
vol.Required(CONF_ZONE): cv.string,
|
cv.deprecated(CONF_RECORDS, invalidation_version="0.119"),
|
||||||
vol.Required(CONF_RECORDS): vol.All(cv.ensure_list, [cv.string]),
|
vol.Schema(
|
||||||
}
|
{
|
||||||
|
vol.Optional(CONF_EMAIL): cv.string,
|
||||||
|
vol.Optional(CONF_API_KEY): cv.string,
|
||||||
|
vol.Optional(CONF_ZONE): cv.string,
|
||||||
|
vol.Optional(CONF_RECORDS): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def setup(hass, config):
|
async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
|
||||||
"""Set up the Cloudflare component."""
|
"""Set up the component."""
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
|
||||||
cfupdate = CloudflareUpdater()
|
if len(hass.config_entries.async_entries(DOMAIN)) > 0:
|
||||||
email = config[DOMAIN][CONF_EMAIL]
|
return True
|
||||||
key = config[DOMAIN][CONF_API_KEY]
|
|
||||||
zone = config[DOMAIN][CONF_ZONE]
|
|
||||||
records = config[DOMAIN][CONF_RECORDS]
|
|
||||||
|
|
||||||
def update_records_interval(now):
|
if DOMAIN in config and CONF_API_KEY in config[DOMAIN]:
|
||||||
"""Set up recurring update."""
|
persistent_notification.async_create(
|
||||||
_update_cloudflare(cfupdate, email, key, zone, records)
|
hass,
|
||||||
|
"Cloudflare integration now requires an API Token. Please go to the integrations page to setup.",
|
||||||
|
"Cloudflare Setup",
|
||||||
|
"cloudflare_setup",
|
||||||
|
)
|
||||||
|
|
||||||
def update_records_service(now):
|
|
||||||
"""Set up service for manual trigger."""
|
|
||||||
_update_cloudflare(cfupdate, email, key, zone, records)
|
|
||||||
|
|
||||||
track_time_interval(hass, update_records_interval, INTERVAL)
|
|
||||||
hass.services.register(DOMAIN, "update_records", update_records_service)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _update_cloudflare(cfupdate, email, key, zone, records):
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Update DNS records for a given zone."""
|
"""Set up Cloudflare from a config entry."""
|
||||||
_LOGGER.debug("Starting update for zone %s", zone)
|
cfupdate = CloudflareUpdater(
|
||||||
|
async_get_clientsession(hass),
|
||||||
|
entry.data[CONF_API_TOKEN],
|
||||||
|
entry.data[CONF_ZONE],
|
||||||
|
entry.data[CONF_RECORDS],
|
||||||
|
)
|
||||||
|
|
||||||
headers = cfupdate.set_header(email, key)
|
try:
|
||||||
_LOGGER.debug("Header data defined as: %s", headers)
|
zone_id = await cfupdate.get_zone_id()
|
||||||
|
except CloudflareAuthenticationException:
|
||||||
|
_LOGGER.error("API access forbidden. Please reauthenticate")
|
||||||
|
return False
|
||||||
|
except CloudflareConnectionException as error:
|
||||||
|
raise ConfigEntryNotReady from error
|
||||||
|
|
||||||
zoneid = cfupdate.get_zoneID(headers, zone)
|
async def update_records(now):
|
||||||
_LOGGER.debug("Zone ID is set to: %s", zoneid)
|
"""Set up recurring update."""
|
||||||
|
try:
|
||||||
|
await _async_update_cloudflare(cfupdate, zone_id)
|
||||||
|
except CloudflareException as error:
|
||||||
|
_LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error)
|
||||||
|
|
||||||
update_records = cfupdate.get_recordInfo(headers, zoneid, zone, records)
|
async def update_records_service(call):
|
||||||
_LOGGER.debug("Records: %s", update_records)
|
"""Set up service for manual trigger."""
|
||||||
|
try:
|
||||||
|
await _async_update_cloudflare(cfupdate, zone_id)
|
||||||
|
except CloudflareException as error:
|
||||||
|
_LOGGER.error("Error updating zone %s: %s", entry.data[CONF_ZONE], error)
|
||||||
|
|
||||||
result = cfupdate.update_records(headers, zoneid, update_records)
|
update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL)
|
||||||
_LOGGER.debug("Update for zone %s is complete", zone)
|
undo_interval = async_track_time_interval(hass, update_records, update_interval)
|
||||||
|
|
||||||
if result is not True:
|
hass.data[DOMAIN][entry.entry_id] = {
|
||||||
_LOGGER.warning(result)
|
DATA_UNDO_UPDATE_INTERVAL: undo_interval,
|
||||||
|
}
|
||||||
|
|
||||||
|
hass.services.async_register(DOMAIN, SERVICE_UPDATE_RECORDS, update_records_service)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload Cloudflare config entry."""
|
||||||
|
hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_INTERVAL]()
|
||||||
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_update_cloudflare(cfupdate: CloudflareUpdater, zone_id: str):
|
||||||
|
_LOGGER.debug("Starting update for zone %s", cfupdate.zone)
|
||||||
|
|
||||||
|
records = await cfupdate.get_record_info(zone_id)
|
||||||
|
_LOGGER.debug("Records: %s", records)
|
||||||
|
|
||||||
|
await cfupdate.update_records(zone_id, records)
|
||||||
|
_LOGGER.debug("Update for zone %s is complete", cfupdate.zone)
|
||||||
|
|
180
homeassistant/components/cloudflare/config_flow.py
Normal file
180
homeassistant/components/cloudflare/config_flow.py
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
"""Config flow for Cloudflare integration."""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from pycfdns import CloudflareUpdater
|
||||||
|
from pycfdns.exceptions import (
|
||||||
|
CloudflareAuthenticationException,
|
||||||
|
CloudflareConnectionException,
|
||||||
|
CloudflareZoneException,
|
||||||
|
)
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import persistent_notification
|
||||||
|
from homeassistant.config_entries import CONN_CLASS_CLOUD_PUSH, ConfigFlow
|
||||||
|
from homeassistant.const import CONF_API_TOKEN, CONF_ZONE
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
from .const import CONF_RECORDS
|
||||||
|
from .const import DOMAIN # pylint:disable=unused-import
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DATA_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_API_TOKEN): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _zone_schema(zones: Optional[List] = None):
|
||||||
|
"""Zone selection schema."""
|
||||||
|
zones_list = []
|
||||||
|
|
||||||
|
if zones is not None:
|
||||||
|
zones_list = zones
|
||||||
|
|
||||||
|
return vol.Schema({vol.Required(CONF_ZONE): vol.In(zones_list)})
|
||||||
|
|
||||||
|
|
||||||
|
def _records_schema(records: Optional[List] = None):
|
||||||
|
"""Zone records selection schema."""
|
||||||
|
records_dict = {}
|
||||||
|
|
||||||
|
if records:
|
||||||
|
records_dict = {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):
|
||||||
|
"""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 = None
|
||||||
|
|
||||||
|
cfupdate = CloudflareUpdater(
|
||||||
|
async_get_clientsession(hass),
|
||||||
|
data[CONF_API_TOKEN],
|
||||||
|
zone,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
zones = 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
|
||||||
|
|
||||||
|
return {"zones": zones, "records": records}
|
||||||
|
|
||||||
|
|
||||||
|
class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Cloudflare."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
CONNECTION_CLASS = CONN_CLASS_CLOUD_PUSH
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the Cloudflare config flow."""
|
||||||
|
self.cloudflare_config = {}
|
||||||
|
self.zones = None
|
||||||
|
self.records = None
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input: Optional[Dict] = None):
|
||||||
|
"""Handle a flow initiated by the user."""
|
||||||
|
if self._async_current_entries():
|
||||||
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
|
|
||||||
|
assert self.hass
|
||||||
|
persistent_notification.async_dismiss(self.hass, "cloudflare_setup")
|
||||||
|
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
info, errors = await self._async_validate_or_error(user_input)
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
self.cloudflare_config.update(user_input)
|
||||||
|
self.zones = info["zones"]
|
||||||
|
return await self.async_step_zone()
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_zone(self, user_input: Optional[Dict] = None):
|
||||||
|
"""Handle the picking the zone."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
self.cloudflare_config.update(user_input)
|
||||||
|
info, errors = await self._async_validate_or_error(self.cloudflare_config)
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
await self.async_set_unique_id(user_input[CONF_ZONE])
|
||||||
|
self.records = info["records"]
|
||||||
|
|
||||||
|
return await self.async_step_records()
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="zone",
|
||||||
|
data_schema=_zone_schema(self.zones),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_records(self, user_input: Optional[Dict] = None):
|
||||||
|
"""Handle the picking the zone records."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
self.cloudflare_config.update(user_input)
|
||||||
|
title = self.cloudflare_config[CONF_ZONE]
|
||||||
|
return self.async_create_entry(title=title, data=self.cloudflare_config)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="records",
|
||||||
|
data_schema=_records_schema(self.records),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_validate_or_error(self, config):
|
||||||
|
errors = {}
|
||||||
|
info = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = await validate_input(self.hass, config)
|
||||||
|
except CannotConnect:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except InvalidAuth:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except InvalidZone:
|
||||||
|
errors["base"] = "invalid_zone"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
|
||||||
|
return info, errors
|
||||||
|
|
||||||
|
|
||||||
|
class CannotConnect(HomeAssistantError):
|
||||||
|
"""Error to indicate we cannot connect."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidAuth(HomeAssistantError):
|
||||||
|
"""Error to indicate there is invalid auth."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidZone(HomeAssistantError):
|
||||||
|
"""Error to indicate we cannot validate zone exists in account."""
|
15
homeassistant/components/cloudflare/const.py
Normal file
15
homeassistant/components/cloudflare/const.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
"""Constants for Cloudflare."""
|
||||||
|
|
||||||
|
DOMAIN = "cloudflare"
|
||||||
|
|
||||||
|
# Config
|
||||||
|
CONF_RECORDS = "records"
|
||||||
|
|
||||||
|
# Data
|
||||||
|
DATA_UNDO_UPDATE_INTERVAL = "undo_update_interval"
|
||||||
|
|
||||||
|
# Defaults
|
||||||
|
DEFAULT_UPDATE_INTERVAL = 60 # in minutes
|
||||||
|
|
||||||
|
# Services
|
||||||
|
SERVICE_UPDATE_RECORDS = "update_records"
|
|
@ -2,6 +2,7 @@
|
||||||
"domain": "cloudflare",
|
"domain": "cloudflare",
|
||||||
"name": "Cloudflare",
|
"name": "Cloudflare",
|
||||||
"documentation": "https://www.home-assistant.io/integrations/cloudflare",
|
"documentation": "https://www.home-assistant.io/integrations/cloudflare",
|
||||||
"requirements": ["pycfdns==0.0.1"],
|
"requirements": ["pycfdns==1.1.1"],
|
||||||
"codeowners": ["@ludeeus"]
|
"codeowners": ["@ludeeus", "@ctalkington"],
|
||||||
|
"config_flow": true
|
||||||
}
|
}
|
||||||
|
|
35
homeassistant/components/cloudflare/strings.json
Normal file
35
homeassistant/components/cloudflare/strings.json
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"flow_title": "Cloudflare: {name}",
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Connect to Cloudflare",
|
||||||
|
"description": "This integration requires an API Token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.",
|
||||||
|
"data": {
|
||||||
|
"api_token": "[%key:common::config_flow::data::api_token%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zone": {
|
||||||
|
"title": "Choose the Zone to Update",
|
||||||
|
"data": {
|
||||||
|
"zone": "Zone"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"records": {
|
||||||
|
"title": "Choose the Records to Update",
|
||||||
|
"data": {
|
||||||
|
"records": "Records"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"invalid_zone": "Invalid zone"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -35,6 +35,7 @@ FLOWS = [
|
||||||
"canary",
|
"canary",
|
||||||
"cast",
|
"cast",
|
||||||
"cert_expiry",
|
"cert_expiry",
|
||||||
|
"cloudflare",
|
||||||
"control4",
|
"control4",
|
||||||
"coolmaster",
|
"coolmaster",
|
||||||
"coronavirus",
|
"coronavirus",
|
||||||
|
|
|
@ -1288,7 +1288,7 @@ pybotvac==0.0.17
|
||||||
pycarwings2==2.9
|
pycarwings2==2.9
|
||||||
|
|
||||||
# homeassistant.components.cloudflare
|
# homeassistant.components.cloudflare
|
||||||
pycfdns==0.0.1
|
pycfdns==1.1.1
|
||||||
|
|
||||||
# homeassistant.components.channels
|
# homeassistant.components.channels
|
||||||
pychannels==1.0.0
|
pychannels==1.0.0
|
||||||
|
|
|
@ -635,6 +635,9 @@ pyblackbird==0.5
|
||||||
# homeassistant.components.neato
|
# homeassistant.components.neato
|
||||||
pybotvac==0.0.17
|
pybotvac==0.0.17
|
||||||
|
|
||||||
|
# homeassistant.components.cloudflare
|
||||||
|
pycfdns==1.1.1
|
||||||
|
|
||||||
# homeassistant.components.cast
|
# homeassistant.components.cast
|
||||||
pychromecast==7.5.1
|
pychromecast==7.5.1
|
||||||
|
|
||||||
|
|
101
tests/components/cloudflare/__init__.py
Normal file
101
tests/components/cloudflare/__init__.py
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
"""Tests for the Cloudflare integration."""
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from pycfdns import CFRecord
|
||||||
|
|
||||||
|
from homeassistant.components.cloudflare.const import CONF_RECORDS, DOMAIN
|
||||||
|
from homeassistant.const import CONF_API_TOKEN, CONF_ZONE
|
||||||
|
|
||||||
|
from tests.async_mock import AsyncMock, patch
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
ENTRY_CONFIG = {
|
||||||
|
CONF_API_TOKEN: "mock-api-token",
|
||||||
|
CONF_ZONE: "mock.com",
|
||||||
|
CONF_RECORDS: ["ha.mock.com", "homeassistant.mock.com"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ENTRY_OPTIONS = {}
|
||||||
|
|
||||||
|
USER_INPUT = {
|
||||||
|
CONF_API_TOKEN: "mock-api-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
{
|
||||||
|
"id": "zone-record-id",
|
||||||
|
"type": "A",
|
||||||
|
"name": "ha.mock.com",
|
||||||
|
"proxied": True,
|
||||||
|
"content": "127.0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zone-record-id-2",
|
||||||
|
"type": "A",
|
||||||
|
"name": "homeassistant.mock.com",
|
||||||
|
"proxied": True,
|
||||||
|
"content": "127.0.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zone-record-id-3",
|
||||||
|
"type": "A",
|
||||||
|
"name": "mock.com",
|
||||||
|
"proxied": True,
|
||||||
|
"content": "127.0.0.1",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def init_integration(
|
||||||
|
hass,
|
||||||
|
*,
|
||||||
|
data: dict = ENTRY_CONFIG,
|
||||||
|
options: dict = ENTRY_OPTIONS,
|
||||||
|
) -> MockConfigEntry:
|
||||||
|
"""Set up the Cloudflare integration in Home Assistant."""
|
||||||
|
entry = MockConfigEntry(domain=DOMAIN, data=data, options=options)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mock_cfupdate(
|
||||||
|
zone: str = MOCK_ZONE,
|
||||||
|
zone_id: str = MOCK_ZONE_ID,
|
||||||
|
records: List = MOCK_ZONE_RECORDS,
|
||||||
|
):
|
||||||
|
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)
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_async_setup(return_value=True):
|
||||||
|
return patch(
|
||||||
|
"homeassistant.components.cloudflare.async_setup",
|
||||||
|
return_value=return_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_async_setup_entry(return_value=True):
|
||||||
|
return patch(
|
||||||
|
"homeassistant.components.cloudflare.async_setup_entry",
|
||||||
|
return_value=return_value,
|
||||||
|
)
|
28
tests/components/cloudflare/conftest.py
Normal file
28
tests/components/cloudflare/conftest.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
"""Define fixtures available for all tests."""
|
||||||
|
from pytest import fixture
|
||||||
|
|
||||||
|
from . import _get_mock_cfupdate
|
||||||
|
|
||||||
|
from tests.async_mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
@fixture
|
||||||
|
def cfupdate(hass):
|
||||||
|
"""Mock the CloudflareUpdater for easier testing."""
|
||||||
|
mock_cfupdate = _get_mock_cfupdate()
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.cloudflare.CloudflareUpdater",
|
||||||
|
return_value=mock_cfupdate,
|
||||||
|
) as mock_api:
|
||||||
|
yield mock_api
|
||||||
|
|
||||||
|
|
||||||
|
@fixture
|
||||||
|
def cfupdate_flow(hass):
|
||||||
|
"""Mock the CloudflareUpdater for easier config flow testing."""
|
||||||
|
mock_cfupdate = _get_mock_cfupdate()
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.cloudflare.config_flow.CloudflareUpdater",
|
||||||
|
return_value=mock_cfupdate,
|
||||||
|
) as mock_api:
|
||||||
|
yield mock_api
|
166
tests/components/cloudflare/test_config_flow.py
Normal file
166
tests/components/cloudflare/test_config_flow.py
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
"""Test the Cloudflare config flow."""
|
||||||
|
from pycfdns.exceptions import (
|
||||||
|
CloudflareAuthenticationException,
|
||||||
|
CloudflareConnectionException,
|
||||||
|
CloudflareZoneException,
|
||||||
|
)
|
||||||
|
|
||||||
|
from homeassistant.components.cloudflare.const import CONF_RECORDS, DOMAIN
|
||||||
|
from homeassistant.config_entries import SOURCE_USER
|
||||||
|
from homeassistant.const import CONF_API_TOKEN, CONF_SOURCE, CONF_ZONE
|
||||||
|
from homeassistant.data_entry_flow import (
|
||||||
|
RESULT_TYPE_ABORT,
|
||||||
|
RESULT_TYPE_CREATE_ENTRY,
|
||||||
|
RESULT_TYPE_FORM,
|
||||||
|
)
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
ENTRY_CONFIG,
|
||||||
|
USER_INPUT,
|
||||||
|
USER_INPUT_RECORDS,
|
||||||
|
USER_INPUT_ZONE,
|
||||||
|
_patch_async_setup,
|
||||||
|
_patch_async_setup_entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_form(hass, cfupdate_flow):
|
||||||
|
"""Test we get the user initiated form."""
|
||||||
|
await async_setup_component(hass, "persistent_notification", {})
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
USER_INPUT,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "zone"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
USER_INPUT_ZONE,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "records"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
with _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry:
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
USER_INPUT_RECORDS,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["title"] == USER_INPUT_ZONE[CONF_ZONE]
|
||||||
|
|
||||||
|
assert result["data"]
|
||||||
|
assert result["data"][CONF_API_TOKEN] == USER_INPUT[CONF_API_TOKEN]
|
||||||
|
assert result["data"][CONF_ZONE] == USER_INPUT_ZONE[CONF_ZONE]
|
||||||
|
assert result["data"][CONF_RECORDS] == USER_INPUT_RECORDS[CONF_RECORDS]
|
||||||
|
|
||||||
|
assert result["result"]
|
||||||
|
assert result["result"].unique_id == USER_INPUT_ZONE[CONF_ZONE]
|
||||||
|
|
||||||
|
assert len(mock_setup.mock_calls) == 1
|
||||||
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_form_cannot_connect(hass, cfupdate_flow):
|
||||||
|
"""Test we handle cannot connect 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 = CloudflareConnectionException()
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
USER_INPUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"base": "cannot_connect"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_form_invalid_auth(hass, cfupdate_flow):
|
||||||
|
"""Test we handle invalid auth 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 = CloudflareAuthenticationException()
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
USER_INPUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"base": "invalid_auth"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_form_invalid_zone(hass, cfupdate_flow):
|
||||||
|
"""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"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"base": "invalid_zone"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_form_unexpected_exception(hass, cfupdate_flow):
|
||||||
|
"""Test we handle unexpected exception."""
|
||||||
|
instance = cfupdate_flow.return_value
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
instance.get_zones.side_effect = Exception()
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
USER_INPUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"base": "unknown"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_form_single_instance_allowed(hass):
|
||||||
|
"""Test that configuring more than one instance is rejected."""
|
||||||
|
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={CONF_SOURCE: SOURCE_USER},
|
||||||
|
data=USER_INPUT,
|
||||||
|
)
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "single_instance_allowed"
|
58
tests/components/cloudflare/test_init.py
Normal file
58
tests/components/cloudflare/test_init.py
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
"""Test the Cloudflare integration."""
|
||||||
|
from pycfdns.exceptions import CloudflareConnectionException
|
||||||
|
|
||||||
|
from homeassistant.components.cloudflare.const import DOMAIN, SERVICE_UPDATE_RECORDS
|
||||||
|
from homeassistant.config_entries import (
|
||||||
|
ENTRY_STATE_LOADED,
|
||||||
|
ENTRY_STATE_NOT_LOADED,
|
||||||
|
ENTRY_STATE_SETUP_RETRY,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import ENTRY_CONFIG, init_integration
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_entry(hass, cfupdate):
|
||||||
|
"""Test successful unload of entry."""
|
||||||
|
entry = await init_integration(hass)
|
||||||
|
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
assert entry.state == ENTRY_STATE_LOADED
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert entry.state == ENTRY_STATE_NOT_LOADED
|
||||||
|
assert not hass.data.get(DOMAIN)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_async_setup_raises_entry_not_ready(hass, cfupdate):
|
||||||
|
"""Test that it throws ConfigEntryNotReady when exception occurs during setup."""
|
||||||
|
instance = cfupdate.return_value
|
||||||
|
|
||||||
|
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
instance.get_zone_id.side_effect = CloudflareConnectionException()
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
|
||||||
|
assert entry.state == ENTRY_STATE_SETUP_RETRY
|
||||||
|
|
||||||
|
|
||||||
|
async def test_integration_services(hass, cfupdate):
|
||||||
|
"""Test integration services."""
|
||||||
|
instance = cfupdate.return_value
|
||||||
|
|
||||||
|
entry = await init_integration(hass)
|
||||||
|
assert entry.state == ENTRY_STATE_LOADED
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_UPDATE_RECORDS,
|
||||||
|
{},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
instance.update_records.assert_called_once()
|
Loading…
Add table
Add a link
Reference in a new issue