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/mailgun.py
homeassistant/components/*/mailgun.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
https://home-assistant.io/components/ifttt/
"""
from ipaddress import ip_address
import json
import logging
from urllib.parse import urlparse
import requests
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant import config_entries
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']
DEPENDENCIES = ['webhook']
@ -100,43 +97,11 @@ async def async_unload_entry(hass, entry):
hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
return True
@config_entries.HANDLERS.register(DOMAIN)
class ConfigFlow(config_entries.ConfigFlow):
"""Handle an IFTTT config flow."""
async def async_step_user(self, user_input=None):
"""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/'
}
)
config_entry_flow.register_webhook_flow(
DOMAIN,
'IFTTT Webhook',
{
'applet_url': 'https://ifttt.com/maker_webhooks',
'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
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 (
PLATFORM_SCHEMA, BaseNotificationService, ATTR_TITLE, ATTR_TITLE_DEFAULT,
ATTR_DATA)
@ -35,7 +36,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
def get_service(hass, config, discovery_info=None):
"""Get the Mailgun notification service."""
data = hass.data[DATA_MAILGUN]
data = hass.data[MAILGUN_DOMAIN]
mailgun_service = MailgunNotificationService(
data.get(CONF_DOMAIN), data.get(CONF_SANDBOX),
data.get(CONF_API_KEY), config.get(CONF_SENDER),

View file

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

View file

@ -1,7 +1,10 @@
"""Helpers for data entry flows for config entries."""
from functools import partial
from ipaddress import ip_address
from urllib.parse import urlparse
from homeassistant import config_entries
from homeassistant.util.network import is_local
def register_discovery_flow(domain, title, discovery_function,
@ -12,6 +15,14 @@ def register_discovery_flow(domain, title, discovery_function,
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):
"""Handle a discovery config flow."""
@ -84,3 +95,50 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow):
title=self._title,
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."""
from unittest.mock import Mock, patch
from unittest.mock import patch
from homeassistant import data_entry_flow
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 ifttt_events[0].data['webhook_id'] == webhook_id
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."""
from unittest.mock import patch
from unittest.mock import patch, Mock
import pytest
@ -9,7 +9,7 @@ from tests.common import MockConfigEntry, MockModule
@pytest.fixture
def flow_conf(hass):
def discovery_flow_conf(hass):
"""Register a handler."""
handler_conf = {
'discovered': False,
@ -26,7 +26,18 @@ def flow_conf(hass):
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."""
flow = config_entries.HANDLERS['test']()
flow.hass = hass
@ -38,7 +49,7 @@ async def test_single_entry_allowed(hass, flow_conf):
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."""
flow = config_entries.HANDLERS['test']()
flow.hass = hass
@ -51,18 +62,18 @@ async def test_user_no_devices_found(hass, flow_conf):
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."""
flow = config_entries.HANDLERS['test']()
flow.hass = hass
flow_conf['discovered'] = True
discovery_flow_conf['discovered'] = True
result = await flow.async_step_user()
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."""
flow = config_entries.HANDLERS['test']()
flow.hass = hass
@ -74,7 +85,7 @@ async def test_discovery_single_instance(hass, flow_conf):
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."""
flow = config_entries.HANDLERS['test']()
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
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."""
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
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."""
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
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."""
flow = config_entries.HANDLERS['test']()
flow.hass = hass
flow_conf['discovered'] = True
discovery_flow_conf['discovered'] = True
result = await flow.async_step_import(None)
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."""
flow = config_entries.HANDLERS['test']()
flow.hass = hass
flow_conf['discovered'] = True
discovery_flow_conf['discovered'] = True
MockConfigEntry(domain='test').add_to_hass(hass)
result = await flow.async_step_import(None)
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