Migrate Mailgun to use the webhook component (#17464)

* Switch mailgun to use webhook api

* Generalize webhook_config_entry_flow

* Add tests for webhook_config_entry_flow

* Add tests for mailgun

* Remove old mailgun file from .coveragerc

* Refactor WebhookFlowHandler into config_entry_flow

* Remove test of helper func from IFTTT

* Lint
This commit is contained in:
Rohan Kapoor 2018-10-23 02:14:46 -07:00 committed by Paulus Schoutsen
parent 277a9a3995
commit d5a5695411
13 changed files with 289 additions and 122 deletions

View File

@ -209,7 +209,6 @@ omit =
homeassistant/components/lutron_caseta.py homeassistant/components/lutron_caseta.py
homeassistant/components/*/lutron_caseta.py homeassistant/components/*/lutron_caseta.py
homeassistant/components/mailgun.py
homeassistant/components/*/mailgun.py homeassistant/components/*/mailgun.py
homeassistant/components/matrix.py homeassistant/components/matrix.py

View File

@ -4,18 +4,15 @@ Support to trigger Maker IFTTT recipes.
For more details about this component, please refer to the documentation at For more details about this component, please refer to the documentation at
https://home-assistant.io/components/ifttt/ https://home-assistant.io/components/ifttt/
""" """
from ipaddress import ip_address
import json import json
import logging import logging
from urllib.parse import urlparse
import requests import requests
import voluptuous as vol import voluptuous as vol
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant import config_entries
from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.util.network import is_local from homeassistant.helpers import config_entry_flow
REQUIREMENTS = ['pyfttt==0.3'] REQUIREMENTS = ['pyfttt==0.3']
DEPENDENCIES = ['webhook'] DEPENDENCIES = ['webhook']
@ -100,43 +97,11 @@ async def async_unload_entry(hass, entry):
hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
return True return True
config_entry_flow.register_webhook_flow(
@config_entries.HANDLERS.register(DOMAIN) DOMAIN,
class ConfigFlow(config_entries.ConfigFlow): 'IFTTT Webhook',
"""Handle an IFTTT config flow.""" {
'applet_url': 'https://ifttt.com/maker_webhooks',
async def async_step_user(self, user_input=None): 'docs_url': 'https://www.home-assistant.io/components/ifttt/'
"""Handle a user initiated set up flow.""" }
if self._async_current_entries(): )
return self.async_abort(reason='one_instance_allowed')
try:
url_parts = urlparse(self.hass.config.api.base_url)
if is_local(ip_address(url_parts.hostname)):
return self.async_abort(reason='not_internet_accessible')
except ValueError:
# If it's not an IP address, it's very likely publicly accessible
pass
if user_input is None:
return self.async_show_form(
step_id='user',
)
webhook_id = self.hass.components.webhook.async_generate_id()
webhook_url = \
self.hass.components.webhook.async_generate_url(webhook_id)
return self.async_create_entry(
title='IFTTT Webhook',
data={
CONF_WEBHOOK_ID: webhook_id
},
description_placeholders={
'applet_url': 'https://ifttt.com/maker_webhooks',
'webhook_url': webhook_url,
'docs_url':
'https://www.home-assistant.io/components/ifttt/'
}
)

View File

@ -1,50 +0,0 @@
"""
Support for Mailgun.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/mailgun/
"""
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.const import CONF_API_KEY, CONF_DOMAIN
from homeassistant.core import callback
from homeassistant.components.http import HomeAssistantView
DOMAIN = 'mailgun'
API_PATH = '/api/{}'.format(DOMAIN)
DATA_MAILGUN = DOMAIN
DEPENDENCIES = ['http']
MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN)
CONF_SANDBOX = 'sandbox'
DEFAULT_SANDBOX = False
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Required(CONF_API_KEY): cv.string,
vol.Required(CONF_DOMAIN): cv.string,
vol.Optional(CONF_SANDBOX, default=DEFAULT_SANDBOX): cv.boolean
}),
}, extra=vol.ALLOW_EXTRA)
def setup(hass, config):
"""Set up the Mailgun component."""
hass.data[DATA_MAILGUN] = config[DOMAIN]
hass.http.register_view(MailgunReceiveMessageView())
return True
class MailgunReceiveMessageView(HomeAssistantView):
"""Handle data from Mailgun inbound messages."""
url = API_PATH
name = 'api:{}'.format(DOMAIN)
@callback
def post(self, request): # pylint: disable=no-self-use
"""Handle Mailgun message POST."""
hass = request.app['hass']
data = yield from request.post()
hass.bus.async_fire(MESSAGE_RECEIVED, dict(data))

View File

@ -0,0 +1,18 @@
{
"config": {
"title": "Mailgun",
"step": {
"user": {
"title": "Set up the Mailgun Webhook",
"description": "Are you sure you want to set up Mailgun?"
}
},
"abort": {
"one_instance_allowed": "Only a single instance is necessary.",
"not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages."
},
"create_entry": {
"default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
}
}
}

View File

@ -0,0 +1,67 @@
"""
Support for Mailgun.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/mailgun/
"""
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_WEBHOOK_ID
from homeassistant.helpers import config_entry_flow
DOMAIN = 'mailgun'
API_PATH = '/api/{}'.format(DOMAIN)
DEPENDENCIES = ['webhook']
MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN)
CONF_SANDBOX = 'sandbox'
DEFAULT_SANDBOX = False
CONFIG_SCHEMA = vol.Schema({
vol.Optional(DOMAIN): vol.Schema({
vol.Required(CONF_API_KEY): cv.string,
vol.Required(CONF_DOMAIN): cv.string,
vol.Optional(CONF_SANDBOX, default=DEFAULT_SANDBOX): cv.boolean,
vol.Optional(CONF_WEBHOOK_ID): cv.string,
}),
}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass, config):
"""Set up the Mailgun component."""
if DOMAIN not in config:
return True
hass.data[DOMAIN] = config[DOMAIN]
return True
async def handle_webhook(hass, webhook_id, request):
"""Handle incoming webhook with Mailgun inbound messages."""
data = dict(await request.post())
data['webhook_id'] = webhook_id
hass.bus.async_fire(MESSAGE_RECEIVED, data)
async def async_setup_entry(hass, entry):
"""Configure based on config entry."""
hass.components.webhook.async_register(
entry.data[CONF_WEBHOOK_ID], handle_webhook)
return True
async def async_unload_entry(hass, entry):
"""Unload a config entry."""
hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
return True
config_entry_flow.register_webhook_flow(
DOMAIN,
'Mailgun Webhook',
{
'mailgun_url':
'https://www.mailgun.com/blog/a-guide-to-using-mailguns-webhooks',
'docs_url': 'https://www.home-assistant.io/components/mailgun/'
}
)

View File

@ -0,0 +1,18 @@
{
"config": {
"title": "Mailgun",
"step": {
"user": {
"title": "Set up the Mailgun Webhook",
"description": "Are you sure you want to set up Mailgun?"
}
},
"abort": {
"one_instance_allowed": "Only a single instance is necessary.",
"not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages."
},
"create_entry": {
"default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
}
}
}

View File

@ -8,7 +8,8 @@ import logging
import voluptuous as vol import voluptuous as vol
from homeassistant.components.mailgun import CONF_SANDBOX, DATA_MAILGUN from homeassistant.components.mailgun import (
CONF_SANDBOX, DOMAIN as MAILGUN_DOMAIN)
from homeassistant.components.notify import ( from homeassistant.components.notify import (
PLATFORM_SCHEMA, BaseNotificationService, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService, ATTR_TITLE, ATTR_TITLE_DEFAULT,
ATTR_DATA) ATTR_DATA)
@ -35,7 +36,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
def get_service(hass, config, discovery_info=None): def get_service(hass, config, discovery_info=None):
"""Get the Mailgun notification service.""" """Get the Mailgun notification service."""
data = hass.data[DATA_MAILGUN] data = hass.data[MAILGUN_DOMAIN]
mailgun_service = MailgunNotificationService( mailgun_service = MailgunNotificationService(
data.get(CONF_DOMAIN), data.get(CONF_SANDBOX), data.get(CONF_DOMAIN), data.get(CONF_SANDBOX),
data.get(CONF_API_KEY), config.get(CONF_SENDER), data.get(CONF_API_KEY), config.get(CONF_SENDER),

View File

@ -143,6 +143,7 @@ FLOWS = [
'ifttt', 'ifttt',
'ios', 'ios',
'lifx', 'lifx',
'mailgun',
'mqtt', 'mqtt',
'nest', 'nest',
'openuv', 'openuv',

View File

@ -1,7 +1,10 @@
"""Helpers for data entry flows for config entries.""" """Helpers for data entry flows for config entries."""
from functools import partial from functools import partial
from ipaddress import ip_address
from urllib.parse import urlparse
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.util.network import is_local
def register_discovery_flow(domain, title, discovery_function, def register_discovery_flow(domain, title, discovery_function,
@ -12,6 +15,14 @@ def register_discovery_flow(domain, title, discovery_function,
connection_class)) connection_class))
def register_webhook_flow(domain, title, description_placeholder,
allow_multiple=False):
"""Register flow for webhook integrations."""
config_entries.HANDLERS.register(domain)(
partial(WebhookFlowHandler, domain, title, description_placeholder,
allow_multiple))
class DiscoveryFlowHandler(config_entries.ConfigFlow): class DiscoveryFlowHandler(config_entries.ConfigFlow):
"""Handle a discovery config flow.""" """Handle a discovery config flow."""
@ -84,3 +95,50 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow):
title=self._title, title=self._title,
data={}, data={},
) )
class WebhookFlowHandler(config_entries.ConfigFlow):
"""Handle a webhook config flow."""
VERSION = 1
def __init__(self, domain, title, description_placeholder,
allow_multiple):
"""Initialize the discovery config flow."""
self._domain = domain
self._title = title
self._description_placeholder = description_placeholder
self._allow_multiple = allow_multiple
async def async_step_user(self, user_input=None):
"""Handle a user initiated set up flow to create a webhook."""
if not self._allow_multiple and self._async_current_entries():
return self.async_abort(reason='one_instance_allowed')
try:
url_parts = urlparse(self.hass.config.api.base_url)
if is_local(ip_address(url_parts.hostname)):
return self.async_abort(reason='not_internet_accessible')
except ValueError:
# If it's not an IP address, it's very likely publicly accessible
pass
if user_input is None:
return self.async_show_form(
step_id='user',
)
webhook_id = self.hass.components.webhook.async_generate_id()
webhook_url = \
self.hass.components.webhook.async_generate_url(webhook_id)
self._description_placeholder['webhook_url'] = webhook_url
return self.async_create_entry(
title=self._title,
data={
'webhook_id': webhook_id
},
description_placeholders=self._description_placeholder
)

View File

@ -1,5 +1,5 @@
"""Test the init file of IFTTT.""" """Test the init file of IFTTT."""
from unittest.mock import Mock, patch from unittest.mock import patch
from homeassistant import data_entry_flow from homeassistant import data_entry_flow
from homeassistant.core import callback from homeassistant.core import callback
@ -36,13 +36,3 @@ async def test_config_flow_registers_webhook(hass, aiohttp_client):
assert len(ifttt_events) == 1 assert len(ifttt_events) == 1
assert ifttt_events[0].data['webhook_id'] == webhook_id assert ifttt_events[0].data['webhook_id'] == webhook_id
assert ifttt_events[0].data['hello'] == 'ifttt' assert ifttt_events[0].data['hello'] == 'ifttt'
async def test_config_flow_aborts_external_url(hass, aiohttp_client):
"""Test setting up IFTTT and sending webhook."""
hass.config.api = Mock(base_url='http://192.168.1.10')
result = await hass.config_entries.flow.async_init('ifttt', context={
'source': 'user'
})
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'not_internet_accessible'

View File

@ -0,0 +1 @@
"""Tests for the Mailgun component."""

View File

@ -0,0 +1,39 @@
"""Test the init file of Mailgun."""
from unittest.mock import patch
from homeassistant import data_entry_flow
from homeassistant.components import mailgun
from homeassistant.core import callback
async def test_config_flow_registers_webhook(hass, aiohttp_client):
"""Test setting up Mailgun and sending webhook."""
with patch('homeassistant.util.get_local_ip', return_value='example.com'):
result = await hass.config_entries.flow.async_init('mailgun', context={
'source': 'user'
})
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
result = await hass.config_entries.flow.async_configure(
result['flow_id'], {})
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
webhook_id = result['result'].data['webhook_id']
mailgun_events = []
@callback
def handle_event(event):
"""Handle Mailgun event."""
mailgun_events.append(event)
hass.bus.async_listen(mailgun.MESSAGE_RECEIVED, handle_event)
client = await aiohttp_client(hass.http.app)
await client.post('/api/webhook/{}'.format(webhook_id), data={
'hello': 'mailgun'
})
assert len(mailgun_events) == 1
assert mailgun_events[0].data['webhook_id'] == webhook_id
assert mailgun_events[0].data['hello'] == 'mailgun'

View File

@ -1,5 +1,5 @@
"""Tests for the Config Entry Flow helper.""" """Tests for the Config Entry Flow helper."""
from unittest.mock import patch from unittest.mock import patch, Mock
import pytest import pytest
@ -9,7 +9,7 @@ from tests.common import MockConfigEntry, MockModule
@pytest.fixture @pytest.fixture
def flow_conf(hass): def discovery_flow_conf(hass):
"""Register a handler.""" """Register a handler."""
handler_conf = { handler_conf = {
'discovered': False, 'discovered': False,
@ -26,7 +26,18 @@ def flow_conf(hass):
yield handler_conf yield handler_conf
async def test_single_entry_allowed(hass, flow_conf): @pytest.fixture
def webhook_flow_conf(hass):
"""Register a handler."""
with patch.dict(config_entries.HANDLERS):
config_entry_flow.register_webhook_flow(
'test_single', 'Test Single', {}, False)
config_entry_flow.register_webhook_flow(
'test_multiple', 'Test Multiple', {}, True)
yield {}
async def test_single_entry_allowed(hass, discovery_flow_conf):
"""Test only a single entry is allowed.""" """Test only a single entry is allowed."""
flow = config_entries.HANDLERS['test']() flow = config_entries.HANDLERS['test']()
flow.hass = hass flow.hass = hass
@ -38,7 +49,7 @@ async def test_single_entry_allowed(hass, flow_conf):
assert result['reason'] == 'single_instance_allowed' assert result['reason'] == 'single_instance_allowed'
async def test_user_no_devices_found(hass, flow_conf): async def test_user_no_devices_found(hass, discovery_flow_conf):
"""Test if no devices found.""" """Test if no devices found."""
flow = config_entries.HANDLERS['test']() flow = config_entries.HANDLERS['test']()
flow.hass = hass flow.hass = hass
@ -51,18 +62,18 @@ async def test_user_no_devices_found(hass, flow_conf):
assert result['reason'] == 'no_devices_found' assert result['reason'] == 'no_devices_found'
async def test_user_has_confirmation(hass, flow_conf): async def test_user_has_confirmation(hass, discovery_flow_conf):
"""Test user requires no confirmation to setup.""" """Test user requires no confirmation to setup."""
flow = config_entries.HANDLERS['test']() flow = config_entries.HANDLERS['test']()
flow.hass = hass flow.hass = hass
flow_conf['discovered'] = True discovery_flow_conf['discovered'] = True
result = await flow.async_step_user() result = await flow.async_step_user()
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
async def test_discovery_single_instance(hass, flow_conf): async def test_discovery_single_instance(hass, discovery_flow_conf):
"""Test we ask for confirmation via discovery.""" """Test we ask for confirmation via discovery."""
flow = config_entries.HANDLERS['test']() flow = config_entries.HANDLERS['test']()
flow.hass = hass flow.hass = hass
@ -74,7 +85,7 @@ async def test_discovery_single_instance(hass, flow_conf):
assert result['reason'] == 'single_instance_allowed' assert result['reason'] == 'single_instance_allowed'
async def test_discovery_confirmation(hass, flow_conf): async def test_discovery_confirmation(hass, discovery_flow_conf):
"""Test we ask for confirmation via discovery.""" """Test we ask for confirmation via discovery."""
flow = config_entries.HANDLERS['test']() flow = config_entries.HANDLERS['test']()
flow.hass = hass flow.hass = hass
@ -88,7 +99,7 @@ async def test_discovery_confirmation(hass, flow_conf):
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
async def test_multiple_discoveries(hass, flow_conf): async def test_multiple_discoveries(hass, discovery_flow_conf):
"""Test we only create one instance for multiple discoveries.""" """Test we only create one instance for multiple discoveries."""
loader.set_component(hass, 'test', MockModule('test')) loader.set_component(hass, 'test', MockModule('test'))
@ -102,7 +113,7 @@ async def test_multiple_discoveries(hass, flow_conf):
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
async def test_only_one_in_progress(hass, flow_conf): async def test_only_one_in_progress(hass, discovery_flow_conf):
"""Test a user initialized one will finish and cancel discovered one.""" """Test a user initialized one will finish and cancel discovered one."""
loader.set_component(hass, 'test', MockModule('test')) loader.set_component(hass, 'test', MockModule('test'))
@ -127,22 +138,71 @@ async def test_only_one_in_progress(hass, flow_conf):
assert len(hass.config_entries.flow.async_progress()) == 0 assert len(hass.config_entries.flow.async_progress()) == 0
async def test_import_no_confirmation(hass, flow_conf): async def test_import_no_confirmation(hass, discovery_flow_conf):
"""Test import requires no confirmation to set up.""" """Test import requires no confirmation to set up."""
flow = config_entries.HANDLERS['test']() flow = config_entries.HANDLERS['test']()
flow.hass = hass flow.hass = hass
flow_conf['discovered'] = True discovery_flow_conf['discovered'] = True
result = await flow.async_step_import(None) result = await flow.async_step_import(None)
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
async def test_import_single_instance(hass, flow_conf): async def test_import_single_instance(hass, discovery_flow_conf):
"""Test import doesn't create second instance.""" """Test import doesn't create second instance."""
flow = config_entries.HANDLERS['test']() flow = config_entries.HANDLERS['test']()
flow.hass = hass flow.hass = hass
flow_conf['discovered'] = True discovery_flow_conf['discovered'] = True
MockConfigEntry(domain='test').add_to_hass(hass) MockConfigEntry(domain='test').add_to_hass(hass)
result = await flow.async_step_import(None) result = await flow.async_step_import(None)
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
async def test_webhook_single_entry_allowed(hass, webhook_flow_conf):
"""Test only a single entry is allowed."""
flow = config_entries.HANDLERS['test_single']()
flow.hass = hass
MockConfigEntry(domain='test_single').add_to_hass(hass)
result = await flow.async_step_user()
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'one_instance_allowed'
async def test_webhook_multiple_entries_allowed(hass, webhook_flow_conf):
"""Test multiple entries are allowed when specified."""
flow = config_entries.HANDLERS['test_multiple']()
flow.hass = hass
MockConfigEntry(domain='test_multiple').add_to_hass(hass)
hass.config.api = Mock(base_url='http://example.com')
result = await flow.async_step_user()
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
async def test_webhook_config_flow_aborts_external_url(hass,
webhook_flow_conf):
"""Test configuring a webhook without an external url."""
flow = config_entries.HANDLERS['test_single']()
flow.hass = hass
hass.config.api = Mock(base_url='http://192.168.1.10')
result = await flow.async_step_user()
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'not_internet_accessible'
async def test_webhook_config_flow_registers_webhook(hass, webhook_flow_conf):
"""Test setting up an entry creates a webhook."""
flow = config_entries.HANDLERS['test_single']()
flow.hass = hass
hass.config.api = Mock(base_url='http://example.com')
result = await flow.async_step_user(user_input={})
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result['data']['webhook_id'] is not None