Allow unloading mobile app (#30995)
parent
3c44a1353a
commit
e5365779fe
|
@ -1,5 +1,11 @@
|
|||
"""Integrates Native Apps to Home Assistant."""
|
||||
from homeassistant.components.webhook import async_register as webhook_register
|
||||
import asyncio
|
||||
|
||||
from homeassistant.components import cloud
|
||||
from homeassistant.components.webhook import (
|
||||
async_register as webhook_register,
|
||||
async_unregister as webhook_unregister,
|
||||
)
|
||||
from homeassistant.const import CONF_WEBHOOK_ID
|
||||
from homeassistant.helpers import device_registry as dr, discovery
|
||||
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||
|
@ -10,6 +16,7 @@ from .const import (
|
|||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL,
|
||||
ATTR_OS_VERSION,
|
||||
CONF_CLOUDHOOK_URL,
|
||||
DATA_BINARY_SENSOR,
|
||||
DATA_CONFIG_ENTRIES,
|
||||
DATA_DELETED_IDS,
|
||||
|
@ -20,9 +27,9 @@ from .const import (
|
|||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
)
|
||||
from .helpers import savable_state
|
||||
from .http_api import RegistrationsView
|
||||
from .webhook import handle_webhook
|
||||
from .websocket_api import register_websocket_handlers
|
||||
|
||||
PLATFORMS = "sensor", "binary_sensor", "device_tracker"
|
||||
|
||||
|
@ -49,7 +56,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType):
|
|||
}
|
||||
|
||||
hass.http.register_view(RegistrationsView())
|
||||
register_websocket_handlers(hass)
|
||||
|
||||
for deleted_id in hass.data[DOMAIN][DATA_DELETED_IDS]:
|
||||
try:
|
||||
|
@ -96,3 +102,34 @@ async def async_setup_entry(hass, entry):
|
|||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry):
|
||||
"""Unload a mobile app entry."""
|
||||
unload_ok = all(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_unload(entry, component)
|
||||
for component in PLATFORMS
|
||||
]
|
||||
)
|
||||
)
|
||||
if not unload_ok:
|
||||
return False
|
||||
|
||||
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass, entry):
|
||||
"""Cleanup when entry is removed."""
|
||||
hass.data[DOMAIN][DATA_DELETED_IDS].append(entry.data[CONF_WEBHOOK_ID])
|
||||
store = hass.data[DOMAIN][DATA_STORE]
|
||||
await store.async_save(savable_state(hass))
|
||||
|
||||
if CONF_CLOUDHOOK_URL in entry.data:
|
||||
try:
|
||||
await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID])
|
||||
except cloud.CloudNotAvailable:
|
||||
pass
|
||||
|
|
|
@ -1,121 +0,0 @@
|
|||
"""Websocket API for mobile_app."""
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.websocket_api import (
|
||||
ActiveConnection,
|
||||
async_register_command,
|
||||
async_response,
|
||||
error_message,
|
||||
result_message,
|
||||
websocket_command,
|
||||
ws_require_user,
|
||||
)
|
||||
from homeassistant.components.websocket_api.const import (
|
||||
ERR_INVALID_FORMAT,
|
||||
ERR_NOT_FOUND,
|
||||
ERR_UNAUTHORIZED,
|
||||
)
|
||||
from homeassistant.const import CONF_WEBHOOK_ID
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
from .const import (
|
||||
CONF_CLOUDHOOK_URL,
|
||||
CONF_USER_ID,
|
||||
DATA_CONFIG_ENTRIES,
|
||||
DATA_DELETED_IDS,
|
||||
DATA_STORE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import safe_registration, savable_state
|
||||
|
||||
|
||||
def register_websocket_handlers(hass: HomeAssistantType) -> bool:
|
||||
"""Register the websocket handlers."""
|
||||
async_register_command(hass, websocket_get_user_registrations)
|
||||
|
||||
async_register_command(hass, websocket_delete_registration)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@ws_require_user()
|
||||
@async_response
|
||||
@websocket_command(
|
||||
{
|
||||
vol.Required("type"): "mobile_app/get_user_registrations",
|
||||
vol.Optional(CONF_USER_ID): cv.string,
|
||||
}
|
||||
)
|
||||
async def websocket_get_user_registrations(
|
||||
hass: HomeAssistantType, connection: ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""Return all registrations or just registrations for given user ID."""
|
||||
user_id = msg.get(CONF_USER_ID, connection.user.id)
|
||||
|
||||
if user_id != connection.user.id and not connection.user.is_admin:
|
||||
# If user ID is provided and is not current user ID and current user
|
||||
# isn't an admin user
|
||||
connection.send_error(msg["id"], ERR_UNAUTHORIZED, "Unauthorized")
|
||||
return
|
||||
|
||||
user_registrations = []
|
||||
|
||||
for config_entry in hass.config_entries.async_entries(domain=DOMAIN):
|
||||
registration = config_entry.data
|
||||
if connection.user.is_admin or registration[CONF_USER_ID] is user_id:
|
||||
user_registrations.append(safe_registration(registration))
|
||||
|
||||
connection.send_message(result_message(msg["id"], user_registrations))
|
||||
|
||||
|
||||
@ws_require_user()
|
||||
@async_response
|
||||
@websocket_command(
|
||||
{
|
||||
vol.Required("type"): "mobile_app/delete_registration",
|
||||
vol.Required(CONF_WEBHOOK_ID): cv.string,
|
||||
}
|
||||
)
|
||||
async def websocket_delete_registration(
|
||||
hass: HomeAssistantType, connection: ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""Delete the registration for the given webhook_id."""
|
||||
user = connection.user
|
||||
|
||||
webhook_id = msg.get(CONF_WEBHOOK_ID)
|
||||
if webhook_id is None:
|
||||
connection.send_error(msg["id"], ERR_INVALID_FORMAT, "Webhook ID not provided")
|
||||
return
|
||||
|
||||
config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id]
|
||||
|
||||
registration = config_entry.data
|
||||
|
||||
if registration is None:
|
||||
connection.send_error(
|
||||
msg["id"], ERR_NOT_FOUND, "Webhook ID not found in storage"
|
||||
)
|
||||
return
|
||||
|
||||
if registration[CONF_USER_ID] != user.id and not user.is_admin:
|
||||
return error_message(
|
||||
msg["id"], ERR_UNAUTHORIZED, "User is not registration owner"
|
||||
)
|
||||
|
||||
await hass.config_entries.async_remove(config_entry.entry_id)
|
||||
|
||||
hass.data[DOMAIN][DATA_DELETED_IDS].append(webhook_id)
|
||||
|
||||
store = hass.data[DOMAIN][DATA_STORE]
|
||||
|
||||
try:
|
||||
await store.async_save(savable_state(hass))
|
||||
except HomeAssistantError:
|
||||
return error_message(msg["id"], "internal_error", "Error deleting registration")
|
||||
|
||||
if CONF_CLOUDHOOK_URL in registration:
|
||||
await hass.components.cloud.async_delete_cloudhook(webhook_id)
|
||||
|
||||
connection.send_message(result_message(msg["id"], "ok"))
|
|
@ -19,6 +19,8 @@ def registry(hass):
|
|||
@pytest.fixture
|
||||
async def create_registrations(hass, authed_api_client):
|
||||
"""Return two new registrations."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
enc_reg = await authed_api_client.post(
|
||||
"/api/mobile_app/registrations", json=REGISTER
|
||||
)
|
||||
|
@ -39,11 +41,13 @@ async def create_registrations(hass, authed_api_client):
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
async def webhook_client(hass, aiohttp_client):
|
||||
async def webhook_client(hass, authed_api_client, aiohttp_client):
|
||||
"""mobile_app mock client."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
return await aiohttp_client(hass.http.app)
|
||||
# We pass in the authed_api_client server instance because
|
||||
# it is used inside create_registrations and just passing in
|
||||
# the app instance would cause the server to start twice,
|
||||
# which caused deprecation warnings to be printed.
|
||||
return await aiohttp_client(authed_api_client.server)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
"""Tests for the mobile app integration."""
|
||||
from homeassistant.components.mobile_app.const import DATA_DELETED_IDS, DOMAIN
|
||||
|
||||
from .const import CALL_SERVICE
|
||||
|
||||
from tests.common import async_mock_service
|
||||
|
||||
|
||||
async def test_unload_unloads(hass, create_registrations, webhook_client):
|
||||
"""Test we clean up when we unload."""
|
||||
# Second config entry is the one without encryption
|
||||
config_entry = hass.config_entries.async_entries("mobile_app")[1]
|
||||
webhook_id = config_entry.data["webhook_id"]
|
||||
calls = async_mock_service(hass, "test", "mobile_app")
|
||||
|
||||
# Test it works
|
||||
await webhook_client.post(f"/api/webhook/{webhook_id}", json=CALL_SERVICE)
|
||||
assert len(calls) == 1
|
||||
|
||||
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
|
||||
# Test it no longer works
|
||||
await webhook_client.post(f"/api/webhook/{webhook_id}", json=CALL_SERVICE)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_remove_entry(hass, create_registrations):
|
||||
"""Test we clean up when we remove entry."""
|
||||
for config_entry in hass.config_entries.async_entries("mobile_app"):
|
||||
await hass.config_entries.async_remove(config_entry.entry_id)
|
||||
assert config_entry.data["webhook_id"] in hass.data[DOMAIN][DATA_DELETED_IDS]
|
||||
|
||||
dev_reg = await hass.helpers.device_registry.async_get_registry()
|
||||
assert len(dev_reg.devices) == 0
|
||||
|
||||
ent_reg = await hass.helpers.entity_registry.async_get_registry()
|
||||
assert len(ent_reg.entities) == 0
|
|
@ -67,9 +67,8 @@ async def test_webhook_handle_fire_event(hass, create_registrations, webhook_cli
|
|||
assert events[0].data["hello"] == "yo world"
|
||||
|
||||
|
||||
async def test_webhook_update_registration(webhook_client, hass_client):
|
||||
async def test_webhook_update_registration(webhook_client, authed_api_client):
|
||||
"""Test that a we can update an existing registration via webhook."""
|
||||
authed_api_client = await hass_client()
|
||||
register_resp = await authed_api_client.post(
|
||||
"/api/mobile_app/registrations", json=REGISTER_CLEARTEXT
|
||||
)
|
||||
|
|
|
@ -1,76 +0,0 @@
|
|||
"""Test the mobile_app websocket API."""
|
||||
# pylint: disable=redefined-outer-name,unused-import
|
||||
from homeassistant.components.mobile_app.const import CONF_SECRET, DOMAIN
|
||||
from homeassistant.components.websocket_api.const import TYPE_RESULT
|
||||
from homeassistant.const import CONF_WEBHOOK_ID
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .const import CALL_SERVICE, REGISTER
|
||||
|
||||
|
||||
async def test_webocket_get_user_registrations(
|
||||
hass, aiohttp_client, hass_ws_client, hass_read_only_access_token
|
||||
):
|
||||
"""Test get_user_registrations websocket command from admin perspective."""
|
||||
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
|
||||
user_api_client = await aiohttp_client(
|
||||
hass.http.app,
|
||||
headers={"Authorization": "Bearer {}".format(hass_read_only_access_token)},
|
||||
)
|
||||
|
||||
# First a read only user registers.
|
||||
register_resp = await user_api_client.post(
|
||||
"/api/mobile_app/registrations", json=REGISTER
|
||||
)
|
||||
|
||||
assert register_resp.status == 201
|
||||
register_json = await register_resp.json()
|
||||
assert CONF_WEBHOOK_ID in register_json
|
||||
assert CONF_SECRET in register_json
|
||||
|
||||
# Then the admin user attempts to access it.
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json({"id": 5, "type": "mobile_app/get_user_registrations"})
|
||||
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["id"] == 5
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert msg["success"]
|
||||
assert len(msg["result"]) == 1
|
||||
|
||||
|
||||
async def test_webocket_delete_registration(
|
||||
hass, hass_client, hass_ws_client, webhook_client
|
||||
):
|
||||
"""Test delete_registration websocket command."""
|
||||
authed_api_client = await hass_client() # noqa: F811
|
||||
register_resp = await authed_api_client.post(
|
||||
"/api/mobile_app/registrations", json=REGISTER
|
||||
)
|
||||
|
||||
assert register_resp.status == 201
|
||||
register_json = await register_resp.json()
|
||||
assert CONF_WEBHOOK_ID in register_json
|
||||
assert CONF_SECRET in register_json
|
||||
|
||||
webhook_id = register_json[CONF_WEBHOOK_ID]
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json(
|
||||
{"id": 5, "type": "mobile_app/delete_registration", CONF_WEBHOOK_ID: webhook_id}
|
||||
)
|
||||
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["id"] == 5
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert msg["success"]
|
||||
assert msg["result"] == "ok"
|
||||
|
||||
ensure_four_ten_gone = await webhook_client.post(
|
||||
"/api/webhook/{}".format(webhook_id), json=CALL_SERVICE
|
||||
)
|
||||
|
||||
assert ensure_four_ten_gone.status == 410
|
Loading…
Reference in New Issue