Files
core/tests/components/teslemetry/test_config_flow.py
Brett Adams 572c0e393c Add reconfigure flow in Teslemetry (#160969)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 14:58:14 +01:00

534 lines
16 KiB
Python

"""Test the Teslemetry config flow."""
import time
from typing import Any
from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlparse
from aiohttp import ClientConnectionError
import pytest
from tesla_fleet_api.exceptions import (
InvalidToken,
SubscriptionRequired,
TeslaFleetError,
)
from homeassistant.components.teslemetry.const import (
AUTHORIZE_URL,
CLIENT_ID,
DOMAIN,
TOKEN_URL,
)
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from . import setup_platform
from .const import CONFIG_V1, UNIQUE_ID
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
REDIRECT = "https://example.com/auth/external/callback"
@pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.usefixtures("mock_setup_entry")
async def test_oauth_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.EXTERNAL_STEP
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT,
},
)
assert result["url"].startswith(AUTHORIZE_URL)
parsed_url = urlparse(result["url"])
parsed_query = parse_qs(parsed_url.query)
assert parsed_query["response_type"][0] == "code"
assert parsed_query["client_id"][0] == CLIENT_ID
assert parsed_query["redirect_uri"][0] == REDIRECT
assert parsed_query["state"][0] == state
assert parsed_query["code_challenge"][0]
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"
response = {
"refresh_token": "test_refresh_token",
"access_token": "test_access_token",
"type": "Bearer",
"expires_in": 60,
}
aioclient_mock.clear_requests()
aioclient_mock.post(
TOKEN_URL,
json=response,
)
# Complete OAuth
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == UNIQUE_ID
assert result["data"]["auth_implementation"] == "teslemetry"
assert result["data"]["token"]["refresh_token"] == response["refresh_token"]
assert result["data"]["token"]["access_token"] == response["access_token"]
assert result["data"]["token"]["type"] == response["type"]
assert result["data"]["token"]["expires_in"] == response["expires_in"]
assert "expires_at" in result["result"].data["token"]
@pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reauth(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test reauth flow."""
mock_entry = await setup_platform(hass, [])
result = await mock_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
# Progress from reauth_confirm to external OAuth step
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT,
},
)
client = await hass_client_no_auth()
await client.get(f"/auth/external/callback?code=abcd&state={state}")
aioclient_mock.post(
TOKEN_URL,
json={
"refresh_token": "test_refresh_token",
"access_token": "test_access_token",
"type": "Bearer",
"expires_in": 60,
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
@pytest.mark.usefixtures("current_request_with_host")
async def test_reauth_account_mismatch(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test Tesla Fleet reauthentication with different account."""
# Create an entry with a different unique_id to test account mismatch
old_entry = MockConfigEntry(
domain=DOMAIN,
version=2,
unique_id="baduid",
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": "old_access_token",
"refresh_token": "old_refresh_token",
"expires_at": int(time.time()) + 3600,
},
},
)
old_entry.add_to_hass(hass)
# Setup the integration properly to import client credentials
with patch(
"homeassistant.components.teslemetry.async_setup_entry", return_value=True
):
await hass.config_entries.async_setup(old_entry.entry_id)
await hass.async_block_till_done()
result = await old_entry.start_reauth_flow(hass)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT,
},
)
client = await hass_client_no_auth()
await client.get(f"/auth/external/callback?code=abcd&state={state}")
aioclient_mock.post(
TOKEN_URL,
json={
"refresh_token": "mock-refresh-token",
"access_token": "test_access_token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.teslemetry.async_setup_entry", return_value=True
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_account_mismatch"
@pytest.mark.usefixtures("current_request_with_host")
async def test_duplicate_unique_id_abort(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test duplicate unique ID aborts flow."""
# Create existing entry
await setup_platform(hass, [])
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT,
},
)
client = await hass_client_no_auth()
await client.get(f"/auth/external/callback?code=abcd&state={state}")
aioclient_mock.post(
TOKEN_URL,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
# Complete OAuth - should abort due to duplicate unique_id
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")
@pytest.mark.parametrize(
"exception",
[
InvalidToken,
SubscriptionRequired,
ClientConnectionError,
TeslaFleetError("API error"),
],
)
async def test_oauth_error_handling(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
exception: Exception,
) -> None:
"""Test OAuth flow with various API errors."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT,
},
)
client = await hass_client_no_auth()
await client.get(f"/auth/external/callback?code=abcd&state={state}")
aioclient_mock.post(
TOKEN_URL,
json={
"refresh_token": "test_refresh_token",
"access_token": "test_access_token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"tesla_fleet_api.teslemetry.Teslemetry.metadata",
side_effect=exception,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "oauth_error"
@pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_token_response: dict[str, Any],
) -> None:
"""Test reconfigure flow."""
mock_entry = await setup_platform(hass, [])
client = await hass_client_no_auth()
result = await mock_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.EXTERNAL_STEP
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT,
},
)
await client.get(f"/auth/external/callback?code=abcd&state={state}")
new_token_response = mock_token_response | {
"refresh_token": "new_refresh_token",
"access_token": "new_access_token",
}
aioclient_mock.post(TOKEN_URL, json=new_token_response)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
# Verify entry data was updated
assert mock_entry.data["auth_implementation"] == DOMAIN
assert mock_entry.data["token"]["refresh_token"] == "new_refresh_token"
assert mock_entry.data["token"]["access_token"] == "new_access_token"
assert mock_entry.data["token"]["type"] == "Bearer"
assert mock_entry.data["token"]["expires_in"] == 60
assert "expires_at" in mock_entry.data["token"]
@pytest.mark.usefixtures("current_request_with_host")
async def test_reconfigure_account_mismatch(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_token_response: dict[str, Any],
) -> None:
"""Test reconfigure with different account."""
# Create an entry with a different unique_id to test account mismatch
old_entry = MockConfigEntry(
domain=DOMAIN,
version=2,
unique_id="baduid",
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": "old_access_token",
"refresh_token": "old_refresh_token",
"expires_at": int(time.time()) + 3600,
},
},
)
old_entry.add_to_hass(hass)
# Setup the integration properly to import client credentials
with patch(
"homeassistant.components.teslemetry.async_setup_entry", return_value=True
):
await hass.config_entries.async_setup(old_entry.entry_id)
await hass.async_block_till_done()
client = await hass_client_no_auth()
result = await old_entry.start_reconfigure_flow(hass)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT,
},
)
await client.get(f"/auth/external/callback?code=abcd&state={state}")
aioclient_mock.post(TOKEN_URL, json=mock_token_response)
with patch(
"homeassistant.components.teslemetry.async_setup_entry", return_value=True
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_account_mismatch"
@pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.parametrize(
"exception",
[
InvalidToken,
SubscriptionRequired,
ClientConnectionError,
TeslaFleetError("API error"),
],
)
async def test_reconfigure_oauth_error_handling(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_token_response: dict[str, Any],
exception: Exception,
) -> None:
"""Test reconfigure flow with various API errors."""
mock_entry = await setup_platform(hass, [])
client = await hass_client_no_auth()
result = await mock_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.EXTERNAL_STEP
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT,
},
)
await client.get(f"/auth/external/callback?code=abcd&state={state}")
aioclient_mock.post(TOKEN_URL, json=mock_token_response)
with patch(
"tesla_fleet_api.teslemetry.Teslemetry.metadata",
side_effect=exception,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "oauth_error"
@pytest.mark.usefixtures("current_request_with_host")
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure_oauth_error_recovery(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_token_response: dict[str, Any],
) -> None:
"""Test reconfigure flow can recover from an OAuth error."""
mock_entry = await setup_platform(hass, [])
client = await hass_client_no_auth()
# First attempt - simulate OAuth error
result = await mock_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.EXTERNAL_STEP
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT,
},
)
await client.get(f"/auth/external/callback?code=abcd&state={state}")
aioclient_mock.post(TOKEN_URL, json=mock_token_response)
with patch(
"tesla_fleet_api.teslemetry.Teslemetry.metadata",
side_effect=ClientConnectionError,
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "oauth_error"
# Second attempt - should succeed (recovery)
result = await mock_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.EXTERNAL_STEP
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT,
},
)
await client.get(f"/auth/external/callback?code=abcd&state={state}")
aioclient_mock.clear_requests()
new_token_response = mock_token_response | {
"refresh_token": "new_refresh_token",
"access_token": "new_access_token",
}
aioclient_mock.post(TOKEN_URL, json=new_token_response)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
# Verify entry data was updated after recovery
assert mock_entry.data["token"]["refresh_token"] == "new_refresh_token"
assert mock_entry.data["token"]["access_token"] == "new_access_token"
async def test_migrate_error_from_future(
hass: HomeAssistant, mock_metadata: AsyncMock
) -> None:
"""Test a future version isn't migrated."""
mock_metadata.side_effect = TeslaFleetError
mock_entry = MockConfigEntry(
domain=DOMAIN,
version=3,
minor_version=1,
unique_id="abc-123",
data=CONFIG_V1,
)
mock_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
entry = hass.config_entries.async_get_entry(mock_entry.entry_id)
assert entry.state is ConfigEntryState.MIGRATION_ERROR