"""Test the August config flow.""" from collections.abc import Generator from unittest.mock import ANY, Mock, patch import pytest from homeassistant.components.august.application_credentials import ( OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) from homeassistant.components.august.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator CLIENT_ID = "1" USER_ID = "a76c25e5-49aa-4c14-cd0c-48a6931e2081" @pytest.fixture def mock_setup_entry() -> Generator[Mock]: """Patch setup entry.""" with patch( "homeassistant.components.august.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry @pytest.mark.usefixtures("client_credentials") @pytest.mark.usefixtures("current_request_with_host") @pytest.mark.usefixtures("mock_setup_entry") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, jwt: str, ) -> None: """Check full flow.""" 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": "https://example.com/auth/external/callback", }, ) assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" ) 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.clear_requests() aioclient_mock.post( OAUTH2_TOKEN, json={ "access_token": jwt, "scope": "any", "expires_in": 86399, "refresh_token": "mock-refresh-token", "user_id": "mock-user-id", "expires_at": 1697753347, }, ) result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 entry = hass.config_entries.async_entries(DOMAIN)[0] assert entry.unique_id == USER_ID assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["result"].unique_id == USER_ID assert entry.data == { "auth_implementation": "august", "token": { "access_token": jwt, "expires_at": ANY, "expires_in": ANY, "refresh_token": "mock-refresh-token", "scope": "any", "user_id": "mock-user-id", }, } @pytest.mark.usefixtures("client_credentials") @pytest.mark.usefixtures("current_request_with_host") @pytest.mark.usefixtures("mock_setup_entry") async def test_full_flow_already_exists( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, jwt: str, mock_config_entry: MockConfigEntry, ) -> None: """Check full flow for a user that already exists.""" mock_config_entry.add_to_hass(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": "https://example.com/auth/external/callback", }, ) assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" ) 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.clear_requests() aioclient_mock.post( OAUTH2_TOKEN, json={ "access_token": jwt, "scope": "any", "expires_in": 86399, "refresh_token": "mock-refresh-token", "user_id": "mock-user-id", "expires_at": 1697753347, }, ) result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" @pytest.mark.usefixtures("client_credentials") @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, mock_config_entry: MockConfigEntry, reauth_jwt: str, ) -> None: """Test the reauthentication case updates the existing config entry.""" mock_config_entry.add_to_hass(hass) mock_config_entry.async_start_reauth(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 result = flows[0] assert result["step_id"] == "auth" state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) 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={ "access_token": reauth_jwt, "expires_in": 86399, "refresh_token": "mock-refresh-token", "user_id": USER_ID, "token_type": "Bearer", "expires_at": 1697753347, }, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert mock_config_entry.unique_id == USER_ID assert "token" in mock_config_entry.data # Verify access token is refreshed assert mock_config_entry.data["token"]["access_token"] == reauth_jwt @pytest.mark.usefixtures("client_credentials") @pytest.mark.usefixtures("current_request_with_host") @pytest.mark.usefixtures("mock_setup_entry") async def test_reauth_wrong_account( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, mock_config_entry: MockConfigEntry, reauth_jwt_wrong_account: str, jwt: str, ) -> None: """Test the reauthentication aborts, if user tries to reauthenticate with another account.""" assert mock_config_entry.data["token"]["access_token"] == jwt mock_config_entry.add_to_hass(hass) mock_config_entry.async_start_reauth(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 result = flows[0] assert result["step_id"] == "auth" state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) 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={ "access_token": reauth_jwt_wrong_account, "expires_in": 86399, "refresh_token": "mock-refresh-token", "token_type": "Bearer", "expires_at": 1697753347, }, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_invalid_user" assert mock_config_entry.unique_id == USER_ID assert "token" in mock_config_entry.data # Verify access token is like before assert mock_config_entry.data["token"]["access_token"] == jwt @pytest.mark.usefixtures("client_credentials") @pytest.mark.usefixtures("current_request_with_host") @pytest.mark.usefixtures("mock_setup_entry") async def test_legacy_migration_with_email_match( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, mock_legacy_config_entry: MockConfigEntry, migration_jwt: str, ) -> None: """Test migration from legacy username/password config to OAuth with email validation.""" mock_legacy_config_entry.add_to_hass(hass) # Start reauth flow (triggered by ConfigEntryAuthFailed in async_setup_entry) mock_legacy_config_entry.async_start_reauth(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 result = flows[0] assert result["step_id"] == "auth" state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) 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={ "access_token": migration_jwt, # JWT with email: ["my@email.tld"] "expires_in": 86399, "refresh_token": "mock-refresh-token", "user_id": USER_ID, "token_type": "Bearer", "expires_at": 1697753347, }, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" # Verify the entry was updated with new unique_id and OAuth data assert mock_legacy_config_entry.unique_id == USER_ID # Updated from email to userId assert "token" in mock_legacy_config_entry.data assert mock_legacy_config_entry.data["auth_implementation"] == "august" assert mock_legacy_config_entry.data["token"]["access_token"] == migration_jwt @pytest.mark.usefixtures("client_credentials") @pytest.mark.usefixtures("current_request_with_host") @pytest.mark.usefixtures("mock_setup_entry") async def test_legacy_migration_wrong_email( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, mock_legacy_config_entry: MockConfigEntry, reauth_jwt_wrong_account: str, ) -> None: """Test migration from legacy config fails when email doesn't match.""" mock_legacy_config_entry.add_to_hass(hass) # Start reauth flow mock_legacy_config_entry.async_start_reauth(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 result = flows[0] assert result["step_id"] == "auth" state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) 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={ "access_token": reauth_jwt_wrong_account, # JWT with email: ["different@email.tld"] "expires_in": 86399, "refresh_token": "mock-refresh-token", "token_type": "Bearer", "expires_at": 1697753347, }, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_invalid_user" # Verify the entry was NOT updated assert mock_legacy_config_entry.unique_id == "my@email.tld" # Still email assert "token" not in mock_legacy_config_entry.data # Still legacy data @pytest.mark.usefixtures("client_credentials") @pytest.mark.usefixtures("current_request_with_host") @pytest.mark.usefixtures("mock_setup_entry") async def test_legacy_migration_no_email_in_jwt( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, mock_legacy_config_entry: MockConfigEntry, jwt: str, # JWT with empty email array ) -> None: """Test migration from legacy config succeeds when JWT has no email (can't validate).""" mock_legacy_config_entry.add_to_hass(hass) # Start reauth flow mock_legacy_config_entry.async_start_reauth(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 result = flows[0] assert result["step_id"] == "auth" state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], "redirect_uri": "https://example.com/auth/external/callback", }, ) 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={ "access_token": jwt, # JWT with email: [] "expires_in": 86399, "refresh_token": "mock-refresh-token", "user_id": USER_ID, "token_type": "Bearer", "expires_at": 1697753347, }, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" # Verify the entry was updated (allowed because no email to validate) assert mock_legacy_config_entry.unique_id == USER_ID # Updated from email to userId assert "token" in mock_legacy_config_entry.data assert mock_legacy_config_entry.data["auth_implementation"] == "august" assert mock_legacy_config_entry.data["token"]["access_token"] == jwt