"""Test the fitbit config flow.""" from collections.abc import Awaitable, Callable from http import HTTPStatus import time from typing import Any from unittest.mock import patch import pytest from requests_mock.mocker import Mocker from homeassistant import config_entries from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir from .conftest import ( CLIENT_ID, DISPLAY_NAME, FAKE_AUTH_IMPL, PROFILE_API_URL, PROFILE_DATA, PROFILE_USER_ID, SERVER_ACCESS_TOKEN, ) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator REDIRECT_URL = "https://example.com/auth/external/callback" @pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, profile: None, setup_credentials: None, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], "redirect_uri": REDIRECT_URL, }, ) assert result["type"] is FlowResultType.EXTERNAL_STEP assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" ) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, ) with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_setup: await hass.config_entries.flow.async_configure(result["flow_id"]) assert len(mock_setup.mock_calls) == 1 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 config_entry = entries[0] assert config_entry.title == DISPLAY_NAME assert config_entry.unique_id == PROFILE_USER_ID data = dict(config_entry.data) assert "token" in data del data["token"]["expires_at"] assert dict(config_entry.data) == { "auth_implementation": FAKE_AUTH_IMPL, "token": SERVER_ACCESS_TOKEN, } @pytest.mark.parametrize( ("status_code", "error_reason"), [ (HTTPStatus.UNAUTHORIZED, "invalid_auth"), (HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"), ], ) @pytest.mark.usefixtures("current_request_with_host") async def test_token_error( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, profile: None, setup_credentials: None, status_code: HTTPStatus, error_reason: str, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], "redirect_uri": REDIRECT_URL, }, ) assert result["type"] is FlowResultType.EXTERNAL_STEP assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" ) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" aioclient_mock.post( OAUTH2_TOKEN, status=status_code, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == error_reason @pytest.mark.parametrize( ("http_status", "json", "error_reason"), [ (HTTPStatus.INTERNAL_SERVER_ERROR, None, "cannot_connect"), (HTTPStatus.FORBIDDEN, None, "cannot_connect"), ( HTTPStatus.UNAUTHORIZED, { "errors": [{"errorType": "invalid_grant"}], }, "invalid_access_token", ), ], ) @pytest.mark.usefixtures("current_request_with_host") async def test_api_failure( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, requests_mock: Mocker, setup_credentials: None, http_status: HTTPStatus, json: Any, error_reason: str, ) -> None: """Test a failure to fetch the profile during the setup flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], "redirect_uri": REDIRECT_URL, }, ) assert result["type"] is FlowResultType.EXTERNAL_STEP assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" ) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, ) requests_mock.register_uri( "GET", PROFILE_API_URL, status_code=http_status, json=json, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == error_reason @pytest.mark.usefixtures("current_request_with_host") async def test_config_entry_already_exists( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], config_entry: MockConfigEntry, ) -> None: """Test that an account may only be configured once.""" # Verify existing config entry entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], "redirect_uri": REDIRECT_URL, }, ) assert result["type"] is FlowResultType.EXTERNAL_STEP assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" ) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "already_configured" @pytest.mark.parametrize( "token_expiration_time", [time.time() + 86400, time.time() - 86400], ids=("token_active", "token_expired"), ) async def test_import_fitbit_config( hass: HomeAssistant, fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, requests_mock: Mocker, ) -> None: """Test that platform configuration is imported successfully.""" requests_mock.register_uri( "POST", OAUTH2_TOKEN, status_code=HTTPStatus.OK, json=SERVER_ACCESS_TOKEN, ) with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_setup: await sensor_platform_setup() assert len(mock_setup.mock_calls) == 1 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 # Verify valid profile can be fetched from the API config_entry = entries[0] assert config_entry.title == DISPLAY_NAME assert config_entry.unique_id == PROFILE_USER_ID data = dict(config_entry.data) # Verify imported values from fitbit.conf and configuration.yaml and # that the token is updated. assert "token" in data expires_at = data["token"]["expires_at"] assert expires_at > time.time() del data["token"]["expires_at"] assert dict(config_entry.data) == { "auth_implementation": DOMAIN, "clock_format": "24H", "monitored_resources": ["activities/steps"], "token": { "access_token": "server-access-token", "refresh_token": "server-refresh-token", "scope": "activity heartrate nutrition profile settings sleep weight", }, "unit_system": "default", } # Verify an issue is raised for deprecated configuration.yaml issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) assert issue assert issue.translation_key == "deprecated_yaml_import" async def test_import_fitbit_config_failure_cannot_connect( hass: HomeAssistant, fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, requests_mock: Mocker, ) -> None: """Test platform configuration fails to import successfully.""" requests_mock.register_uri( "POST", OAUTH2_TOKEN, status_code=HTTPStatus.OK, json=SERVER_ACCESS_TOKEN, ) requests_mock.register_uri( "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR ) with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_setup: await sensor_platform_setup() assert len(mock_setup.mock_calls) == 0 # Verify an issue is raised that we were unable to import configuration issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) assert issue assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" @pytest.mark.parametrize( "status_code", [ (HTTPStatus.UNAUTHORIZED), (HTTPStatus.INTERNAL_SERVER_ERROR), ], ) async def test_import_fitbit_config_cannot_refresh( hass: HomeAssistant, fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, requests_mock: Mocker, status_code: HTTPStatus, ) -> None: """Test platform configuration import fails when refreshing the token.""" requests_mock.register_uri( "POST", OAUTH2_TOKEN, status_code=status_code, json="", ) with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_setup: await sensor_platform_setup() assert len(mock_setup.mock_calls) == 0 # Verify an issue is raised that we were unable to import configuration issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) assert issue assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" async def test_import_fitbit_config_already_exists( hass: HomeAssistant, config_entry: MockConfigEntry, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, requests_mock: Mocker, ) -> None: """Test that platform configuration is not imported if it already exists.""" requests_mock.register_uri( "POST", OAUTH2_TOKEN, status_code=HTTPStatus.OK, json=SERVER_ACCESS_TOKEN, ) # Verify existing config entry entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_config_entry_setup: await integration_setup() assert len(mock_config_entry_setup.mock_calls) == 1 with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_import_setup: await sensor_platform_setup() assert len(mock_import_setup.mock_calls) == 0 # Still one config entry entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 # Verify an issue is raised for deprecated configuration.yaml issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) assert issue assert issue.translation_key == "deprecated_yaml_import" async def test_platform_setup_without_import( hass: HomeAssistant, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, ) -> None: """Test platform configuration.yaml but no existing fitbit.conf credentials.""" with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_setup: await sensor_platform_setup() # Verify no configuration entry is imported since the integration is not # fully setup properly assert len(mock_setup.mock_calls) == 0 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 0 # Verify an issue is raised for deprecated configuration.yaml assert len(issue_registry.issues) == 1 issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) assert issue assert issue.translation_key == "deprecated_yaml_no_import" @pytest.mark.usefixtures("current_request_with_host") async def test_reauth_flow( hass: HomeAssistant, config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, profile: None, setup_credentials: None, ) -> None: """Test OAuth reauthentication flow will update existing config entry.""" config_entry.add_to_hass(hass) entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 # config_entry.req initiates reauth result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_REAUTH, "entry_id": config_entry.entry_id, }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], user_input={}, ) assert result["type"] is FlowResultType.EXTERNAL_STEP state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], "redirect_uri": REDIRECT_URL, }, ) assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=none" ) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" aioclient_mock.post( OAUTH2_TOKEN, json={ "refresh_token": "updated-refresh-token", "access_token": "updated-access-token", "type": "Bearer", "expires_in": "60", }, ) with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_setup: result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "reauth_successful" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 assert config_entry.data["token"]["refresh_token"] == "updated-refresh-token" @pytest.mark.parametrize("profile_id", ["other-user-id"]) @pytest.mark.usefixtures("current_request_with_host") async def test_reauth_wrong_user_id( hass: HomeAssistant, config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, profile: None, setup_credentials: None, ) -> None: """Test OAuth reauthentication where the wrong user is selected.""" config_entry.add_to_hass(hass) entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": config_entries.SOURCE_REAUTH, "entry_id": config_entry.entry_id, }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], user_input={}, ) assert result["type"] is FlowResultType.EXTERNAL_STEP state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], "redirect_uri": REDIRECT_URL, }, ) assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=none" ) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" aioclient_mock.post( OAUTH2_TOKEN, json={ "refresh_token": "updated-refresh-token", "access_token": "updated-access-token", "type": "Bearer", "expires_in": 60, }, ) with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_setup: result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "wrong_account" assert len(mock_setup.mock_calls) == 0 @pytest.mark.parametrize( ("profile_data", "expected_title"), [ (PROFILE_DATA, DISPLAY_NAME), ({"displayName": DISPLAY_NAME}, DISPLAY_NAME), ], ids=("full_profile_data", "display_name_only"), ) @pytest.mark.usefixtures("current_request_with_host") async def test_partial_profile_data( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, profile: None, setup_credentials: None, expected_title: str, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], "redirect_uri": REDIRECT_URL, }, ) assert result["type"] is FlowResultType.EXTERNAL_STEP assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" ) client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") assert resp.status == 200 aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, ) with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_setup: await hass.config_entries.flow.async_configure(result["flow_id"]) assert len(mock_setup.mock_calls) == 1 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 config_entry = entries[0] assert config_entry.title == expected_title