Add onboarding support (#15492)

* Add onboarding support

* Lint

* Address comments

* Mark user step as done if owner user already created
This commit is contained in:
Paulus Schoutsen 2018-07-17 10:49:15 +02:00 committed by GitHub
parent db3cdb288e
commit b0a3207454
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 409 additions and 2 deletions

View file

@ -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()

View file

@ -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'], {

View file

@ -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

View file

@ -0,0 +1,7 @@
"""Constants for the onboarding component."""
DOMAIN = 'onboarding'
STEP_USER = 'user'
STEPS = [
STEP_USER
]

View file

@ -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')

View file

@ -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
}

View file

@ -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

View file

@ -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]