From b0a3207454882e8aa284724c739bad1ff7b99a8b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Jul 2018 10:49:15 +0200 Subject: [PATCH] Add onboarding support (#15492) * Add onboarding support * Lint * Address comments * Mark user step as done if owner user already created --- homeassistant/auth/providers/homeassistant.py | 3 + homeassistant/components/frontend/__init__.py | 14 +- .../components/onboarding/__init__.py | 56 +++++++ homeassistant/components/onboarding/const.py | 7 + homeassistant/components/onboarding/views.py | 106 ++++++++++++++ tests/components/onboarding/__init__.py | 11 ++ tests/components/onboarding/test_init.py | 77 ++++++++++ tests/components/onboarding/test_views.py | 137 ++++++++++++++++++ 8 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/onboarding/__init__.py create mode 100644 homeassistant/components/onboarding/const.py create mode 100644 homeassistant/components/onboarding/views.py create mode 100644 tests/components/onboarding/__init__.py create mode 100644 tests/components/onboarding/test_init.py create mode 100644 tests/components/onboarding/test_views.py diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 17a56bc5f42..b359f67d77f 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -150,6 +150,9 @@ class HassAuthProvider(AuthProvider): async def async_initialize(self): """Initialize the auth provider.""" + if self.data is not None: + return + self.data = Data(self.hass) await self.data.async_load() diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 89233b6c518..958247cadc5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -29,7 +29,7 @@ from homeassistant.util.yaml import load_yaml REQUIREMENTS = ['home-assistant-frontend==20180716.0'] DOMAIN = 'frontend' -DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] +DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', 'onboarding'] CONF_THEMES = 'themes' CONF_EXTRA_HTML_URL = 'extra_html_url' @@ -377,6 +377,16 @@ class IndexView(HomeAssistantView): latest = self.repo_path is not None or \ _is_latest(self.js_option, request) + if not hass.components.onboarding.async_is_onboarded(): + if latest: + location = '/frontend_latest/onboarding.html' + else: + location = '/frontend_es5/onboarding.html' + + return web.Response(status=302, headers={ + 'location': location + }) + no_auth = '1' if hass.config.api.api_password and not request[KEY_AUTHENTICATED]: # do not try to auto connect on load @@ -480,7 +490,7 @@ def websocket_get_translations(hass, connection, msg): Async friendly. """ async def send_translations(): - """Send a camera still.""" + """Send a translation.""" resources = await async_get_translations(hass, msg['language']) connection.send_message_outside(websocket_api.result_message( msg['id'], { diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py new file mode 100644 index 00000000000..6dea5919f09 --- /dev/null +++ b/homeassistant/components/onboarding/__init__.py @@ -0,0 +1,56 @@ +"""Component to help onboard new users.""" +from homeassistant.core import callback +from homeassistant.loader import bind_hass + +from .const import STEPS, STEP_USER, DOMAIN + +DEPENDENCIES = ['http'] +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + + +@bind_hass +@callback +def async_is_onboarded(hass): + """Return if Home Assistant has been onboarded.""" + # Temporarily: if auth not active, always set onboarded=True + if not hass.auth.active: + return True + + return hass.data.get(DOMAIN, True) + + +async def async_setup(hass, config): + """Set up the onboard component.""" + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + data = await store.async_load() + + if data is None: + data = { + 'done': [] + } + + if STEP_USER not in data['done']: + # Users can already have created an owner account via the command line + # If so, mark the user step as done. + has_owner = False + + for user in await hass.auth.async_get_users(): + if user.is_owner: + has_owner = True + break + + if has_owner: + data['done'].append(STEP_USER) + await store.async_save(data) + + if set(data['done']) == set(STEPS): + return True + + hass.data[DOMAIN] = False + + from . import views + + await views.async_setup(hass, data, store) + + return True diff --git a/homeassistant/components/onboarding/const.py b/homeassistant/components/onboarding/const.py new file mode 100644 index 00000000000..3aa106ac18c --- /dev/null +++ b/homeassistant/components/onboarding/const.py @@ -0,0 +1,7 @@ +"""Constants for the onboarding component.""" +DOMAIN = 'onboarding' +STEP_USER = 'user' + +STEPS = [ + STEP_USER +] diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py new file mode 100644 index 00000000000..1a536a1bc43 --- /dev/null +++ b/homeassistant/components/onboarding/views.py @@ -0,0 +1,106 @@ +"""Onboarding views.""" +import asyncio + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator + +from .const import DOMAIN, STEPS, STEP_USER + + +async def async_setup(hass, data, store): + """Setup onboarding.""" + hass.http.register_view(OnboardingView(data, store)) + hass.http.register_view(UserOnboardingView(data, store)) + + +class OnboardingView(HomeAssistantView): + """Returns the onboarding status.""" + + requires_auth = False + url = '/api/onboarding' + name = 'api:onboarding' + + def __init__(self, data, store): + """Initialize the onboarding view.""" + self._store = store + self._data = data + + async def get(self, request): + """Return the onboarding status.""" + return self.json([ + { + 'step': key, + 'done': key in self._data['done'], + } for key in STEPS + ]) + + +class _BaseOnboardingView(HomeAssistantView): + """Base class for onboarding.""" + + requires_auth = False + step = None + + def __init__(self, data, store): + """Initialize the onboarding view.""" + self._store = store + self._data = data + self._lock = asyncio.Lock() + + @callback + def _async_is_done(self): + """Return if this step is done.""" + return self.step in self._data['done'] + + async def _async_mark_done(self, hass): + """Mark step as done.""" + self._data['done'].append(self.step) + await self._store.async_save(self._data) + + hass.data[DOMAIN] = len(self._data) == len(STEPS) + + +class UserOnboardingView(_BaseOnboardingView): + """View to handle onboarding.""" + + url = '/api/onboarding/users' + name = 'api:onboarding:users' + step = STEP_USER + + @RequestDataValidator(vol.Schema({ + vol.Required('name'): str, + vol.Required('username'): str, + vol.Required('password'): str, + })) + async def post(self, request, data): + """Return the manifest.json.""" + hass = request.app['hass'] + + async with self._lock: + if self._async_is_done(): + return self.json_message('User step already done', 403) + + provider = _async_get_hass_provider(hass) + await provider.async_initialize() + + user = await hass.auth.async_create_user(data['name']) + await hass.async_add_executor_job( + provider.data.add_auth, data['username'], data['password']) + credentials = await provider.async_get_or_create_credentials({ + 'username': data['username'] + }) + await hass.auth.async_link_user(user, credentials) + await self._async_mark_done(hass) + + +@callback +def _async_get_hass_provider(hass): + """Get the Home Assistant auth provider.""" + for prv in hass.auth.auth_providers: + if prv.type == 'homeassistant': + return prv + + raise RuntimeError('No Home Assistant provider found') diff --git a/tests/components/onboarding/__init__.py b/tests/components/onboarding/__init__.py new file mode 100644 index 00000000000..62c6dc929a1 --- /dev/null +++ b/tests/components/onboarding/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the onboarding component.""" + +from homeassistant.components import onboarding + + +def mock_storage(hass_storage, data): + """Mock the onboarding storage.""" + hass_storage[onboarding.STORAGE_KEY] = { + 'version': onboarding.STORAGE_VERSION, + 'data': data + } diff --git a/tests/components/onboarding/test_init.py b/tests/components/onboarding/test_init.py new file mode 100644 index 00000000000..57a81a78da3 --- /dev/null +++ b/tests/components/onboarding/test_init.py @@ -0,0 +1,77 @@ +"""Tests for the init.""" +from unittest.mock import patch, Mock + +from homeassistant.setup import async_setup_component +from homeassistant.components import onboarding + +from tests.common import mock_coro, MockUser + +from . import mock_storage + +# Temporarily: if auth not active, always set onboarded=True + + +async def test_not_setup_views_if_onboarded(hass, hass_storage): + """Test if onboarding is done, we don't setup views.""" + mock_storage(hass_storage, { + 'done': onboarding.STEPS + }) + + with patch( + 'homeassistant.components.onboarding.views.async_setup' + ) as mock_setup: + assert await async_setup_component(hass, 'onboarding', {}) + + assert len(mock_setup.mock_calls) == 0 + assert onboarding.DOMAIN not in hass.data + assert onboarding.async_is_onboarded(hass) + + +async def test_setup_views_if_not_onboarded(hass): + """Test if onboarding is not done, we setup views.""" + with patch( + 'homeassistant.components.onboarding.views.async_setup', + return_value=mock_coro() + ) as mock_setup: + assert await async_setup_component(hass, 'onboarding', {}) + + assert len(mock_setup.mock_calls) == 1 + assert onboarding.DOMAIN in hass.data + + with patch('homeassistant.auth.AuthManager.active', return_value=True): + assert not onboarding.async_is_onboarded(hass) + + +async def test_is_onboarded(): + """Test the is onboarded function.""" + hass = Mock() + hass.data = {} + + with patch('homeassistant.auth.AuthManager.active', return_value=False): + assert onboarding.async_is_onboarded(hass) + + with patch('homeassistant.auth.AuthManager.active', return_value=True): + assert onboarding.async_is_onboarded(hass) + + hass.data[onboarding.DOMAIN] = True + assert onboarding.async_is_onboarded(hass) + + hass.data[onboarding.DOMAIN] = False + assert not onboarding.async_is_onboarded(hass) + + +async def test_having_owner_finishes_user_step(hass, hass_storage): + """If owner user already exists, mark user step as complete.""" + MockUser(is_owner=True).add_to_hass(hass) + + with patch( + 'homeassistant.components.onboarding.views.async_setup' + ) as mock_setup, patch.object(onboarding, 'STEPS', [onboarding.STEP_USER]): + assert await async_setup_component(hass, 'onboarding', {}) + + assert len(mock_setup.mock_calls) == 0 + assert onboarding.DOMAIN not in hass.data + assert onboarding.async_is_onboarded(hass) + + done = hass_storage[onboarding.STORAGE_KEY]['data']['done'] + assert onboarding.STEP_USER in done diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py new file mode 100644 index 00000000000..d6a4030190d --- /dev/null +++ b/tests/components/onboarding/test_views.py @@ -0,0 +1,137 @@ +"""Test the onboarding views.""" +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components import onboarding +from homeassistant.components.onboarding import views + +from tests.common import register_auth_provider + +from . import mock_storage + + +@pytest.fixture(autouse=True) +def auth_active(hass): + """Ensure auth is always active.""" + hass.loop.run_until_complete(register_auth_provider(hass, { + 'type': 'homeassistant' + })) + + +async def test_onboarding_progress(hass, hass_storage, aiohttp_client): + """Test fetching progress.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + client = await aiohttp_client(hass.http.app) + + with patch.object(views, 'STEPS', ['hello', 'world']): + resp = await client.get('/api/onboarding') + + assert resp.status == 200 + data = await resp.json() + assert len(data) == 2 + assert data[0] == { + 'step': 'hello', + 'done': True + } + assert data[1] == { + 'step': 'world', + 'done': False + } + + +async def test_onboarding_user_already_done(hass, hass_storage, + aiohttp_client): + """Test creating a new user when user step already done.""" + mock_storage(hass_storage, { + 'done': [views.STEP_USER] + }) + + with patch.object(onboarding, 'STEPS', ['hello', 'world']): + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/users', json={ + 'name': 'Test Name', + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 403 + + +async def test_onboarding_user(hass, hass_storage, aiohttp_client): + """Test creating a new user.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/users', json={ + 'name': 'Test Name', + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 200 + users = await hass.auth.async_get_users() + assert len(users) == 1 + user = users[0] + assert user.name == 'Test Name' + assert len(user.credentials) == 1 + assert user.credentials[0].data['username'] == 'test-user' + + +async def test_onboarding_user_invalid_name(hass, hass_storage, + aiohttp_client): + """Test not providing name.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/users', json={ + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 400 + + +async def test_onboarding_user_race(hass, hass_storage, aiohttp_client): + """Test race condition on creating new user.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp1 = client.post('/api/onboarding/users', json={ + 'name': 'Test 1', + 'username': '1-user', + 'password': '1-pass', + }) + resp2 = client.post('/api/onboarding/users', json={ + 'name': 'Test 2', + 'username': '2-user', + 'password': '2-pass', + }) + + res1, res2 = await asyncio.gather(resp1, resp2) + + assert sorted([res1.status, res2.status]) == [200, 403]