mirror of
https://github.com/home-assistant/core.git
synced 2026-07-06 14:25:34 +01:00
283 lines
9.7 KiB
Python
283 lines
9.7 KiB
Python
"""Tests for the Yoto config flow."""
|
|
|
|
from http import HTTPStatus
|
|
from unittest.mock import MagicMock
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import jwt
|
|
import pytest
|
|
from yoto_api import AuthenticationError
|
|
|
|
from homeassistant.components.yoto.const import DOMAIN, YOTO_AUDIENCE, YOTO_SCOPES
|
|
from homeassistant.config_entries import (
|
|
SOURCE_DHCP,
|
|
SOURCE_REAUTH,
|
|
SOURCE_USER,
|
|
ConfigEntryState,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.data_entry_flow import FlowResultType
|
|
from homeassistant.helpers import config_entry_oauth2_flow
|
|
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
|
|
|
from .conftest import ACCESS_TOKEN, USER_ID
|
|
|
|
from tests.common import MockConfigEntry
|
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
|
from tests.typing import ClientSessionGenerator
|
|
|
|
REDIRECT_URI = "https://example.com/auth/external/callback"
|
|
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
|
|
|
|
|
|
async def _initiate_user_flow(hass: HomeAssistant) -> dict:
|
|
"""Start the OAuth2 user flow and return the EXTERNAL_STEP result."""
|
|
return await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
|
|
|
|
async def _complete_callback(
|
|
hass: HomeAssistant,
|
|
result: dict,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
*,
|
|
refresh_token: str = "mock-refresh-token",
|
|
access_token: str = ACCESS_TOKEN,
|
|
) -> dict:
|
|
"""Drive the OAuth2 callback through the token exchange."""
|
|
state = config_entry_oauth2_flow._encode_jwt(
|
|
hass,
|
|
{"flow_id": result["flow_id"], "redirect_uri": REDIRECT_URI},
|
|
)
|
|
client = await hass_client_no_auth()
|
|
response = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
|
assert response.status == HTTPStatus.OK
|
|
|
|
aioclient_mock.clear_requests()
|
|
aioclient_mock.post(
|
|
TOKEN_URL,
|
|
json={
|
|
"refresh_token": refresh_token,
|
|
"access_token": access_token,
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
},
|
|
)
|
|
return result
|
|
|
|
|
|
async def test_abort_if_no_credentials(hass: HomeAssistant) -> None:
|
|
"""The flow aborts when no application credentials are configured."""
|
|
result = await _initiate_user_flow(hass)
|
|
assert result["type"] is FlowResultType.ABORT
|
|
assert result["reason"] == "missing_credentials"
|
|
|
|
|
|
@pytest.mark.usefixtures(
|
|
"current_request_with_host", "setup_credentials", "mock_setup_entry"
|
|
)
|
|
async def test_full_flow(
|
|
hass: HomeAssistant,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
) -> None:
|
|
"""Walk a happy-path OAuth2 flow end to end."""
|
|
result = await _initiate_user_flow(hass)
|
|
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
|
|
|
parsed = urlparse(result["url"])
|
|
query = {key: value[0] for key, value in parse_qs(parsed.query).items()}
|
|
assert parsed.scheme == "https"
|
|
assert parsed.netloc == "login.yotoplay.com"
|
|
assert parsed.path == "/authorize"
|
|
assert query["audience"] == YOTO_AUDIENCE
|
|
assert query["scope"] == " ".join(YOTO_SCOPES)
|
|
assert query["client_id"] == "CLIENT_ID"
|
|
assert query["redirect_uri"] == REDIRECT_URI
|
|
|
|
await _complete_callback(hass, result, hass_client_no_auth, aioclient_mock)
|
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
|
|
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
|
assert result["title"] == "Yoto"
|
|
assert result["result"].unique_id == USER_ID
|
|
assert result["data"]["auth_implementation"] == DOMAIN
|
|
assert result["data"]["token"]["access_token"] == ACCESS_TOKEN
|
|
|
|
|
|
@pytest.mark.usefixtures(
|
|
"current_request_with_host", "setup_credentials", "mock_setup_entry"
|
|
)
|
|
async def test_dhcp_discovery_flow(
|
|
hass: HomeAssistant,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
) -> None:
|
|
"""A Yoto player found on the LAN walks through OAuth to a new entry."""
|
|
discovery = DhcpServiceInfo(
|
|
ip="10.0.0.42",
|
|
hostname="yoto-player",
|
|
macaddress="6825dd39c3fc",
|
|
)
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_DHCP}, data=discovery
|
|
)
|
|
assert result["type"] is FlowResultType.FORM
|
|
assert result["step_id"] == "oauth_discovery"
|
|
|
|
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
|
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
|
|
|
await _complete_callback(hass, result, hass_client_no_auth, aioclient_mock)
|
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
|
|
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
|
assert result["result"].unique_id == USER_ID
|
|
|
|
|
|
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
|
|
async def test_already_configured(
|
|
hass: HomeAssistant,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Re-authorizing the same account aborts as already configured."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
|
|
result = await _initiate_user_flow(hass)
|
|
await _complete_callback(hass, result, hass_client_no_auth, aioclient_mock)
|
|
|
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
|
|
|
assert result["type"] is FlowResultType.ABORT
|
|
assert result["reason"] == "already_configured"
|
|
|
|
|
|
@pytest.mark.usefixtures(
|
|
"current_request_with_host", "setup_credentials", "mock_setup_entry"
|
|
)
|
|
async def test_reauth_flow(
|
|
hass: HomeAssistant,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Reauthorizing the same account updates the existing entry's token."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
|
|
result = await mock_config_entry.start_reauth_flow(hass)
|
|
assert result["type"] is FlowResultType.FORM
|
|
assert result["step_id"] == "reauth_confirm"
|
|
|
|
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
|
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
|
|
|
await _complete_callback(
|
|
hass,
|
|
result,
|
|
hass_client_no_auth,
|
|
aioclient_mock,
|
|
refresh_token="new-refresh-token",
|
|
)
|
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
|
|
|
assert result["type"] is FlowResultType.ABORT
|
|
assert result["reason"] == "reauth_successful"
|
|
assert mock_config_entry.data["token"]["refresh_token"] == "new-refresh-token"
|
|
|
|
|
|
@pytest.mark.usefixtures(
|
|
"current_request_with_host", "setup_credentials", "mock_setup_entry"
|
|
)
|
|
async def test_reauth_unique_id_mismatch(
|
|
hass: HomeAssistant,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Reauthorizing with a different account aborts."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
|
|
result = await mock_config_entry.start_reauth_flow(hass)
|
|
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
|
|
|
other_token = jwt.encode(
|
|
{"sub": "auth0|other-user"}, "test-secret-long-enough-for-hmac-sha256"
|
|
)
|
|
await _complete_callback(
|
|
hass, result, hass_client_no_auth, aioclient_mock, access_token=other_token
|
|
)
|
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
|
|
|
assert result["type"] is FlowResultType.ABORT
|
|
assert result["reason"] == "unique_id_mismatch"
|
|
|
|
|
|
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
|
|
async def test_reauth_recovers_failed_entry(
|
|
hass: HomeAssistant,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
mock_yoto_client: MagicMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""A successful reauth reloads an entry that failed to authenticate."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
mock_yoto_client.refresh.side_effect = AuthenticationError("denied")
|
|
|
|
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
|
|
|
flows = hass.config_entries.flow.async_progress()
|
|
assert len(flows) == 1
|
|
assert flows[0]["context"]["source"] == SOURCE_REAUTH
|
|
|
|
mock_yoto_client.refresh.side_effect = None
|
|
|
|
result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {})
|
|
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
|
|
|
await _complete_callback(
|
|
hass,
|
|
result,
|
|
hass_client_no_auth,
|
|
aioclient_mock,
|
|
refresh_token="new-refresh-token",
|
|
)
|
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
|
|
|
assert result["type"] is FlowResultType.ABORT
|
|
assert result["reason"] == "reauth_successful"
|
|
await hass.async_block_till_done()
|
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"access_token",
|
|
[
|
|
"not-a-jwt",
|
|
jwt.encode({"foo": "bar"}, "test-secret-long-enough-for-hmac-sha256"),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
|
|
async def test_invalid_access_token(
|
|
hass: HomeAssistant,
|
|
hass_client_no_auth: ClientSessionGenerator,
|
|
aioclient_mock: AiohttpClientMocker,
|
|
access_token: str,
|
|
) -> None:
|
|
"""The flow aborts when the access token is not a usable JWT."""
|
|
result = await _initiate_user_flow(hass)
|
|
await _complete_callback(
|
|
hass, result, hass_client_no_auth, aioclient_mock, access_token=access_token
|
|
)
|
|
|
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
|
|
|
assert result["type"] is FlowResultType.ABORT
|
|
assert result["reason"] == "oauth_unauthorized"
|