From 5c251771000d4198a4ce64ef617902a1da646d93 Mon Sep 17 00:00:00 2001
From: Richard <42204099+rikroe@users.noreply.github.com>
Date: Sat, 2 Nov 2024 00:46:17 +0100
Subject: [PATCH 1/5] Add external flow for captcha
---
.../bmw_connected_drive/config_flow.py | 147 ++++++++++++++++--
.../components/bmw_connected_drive/const.py | 18 +++
.../bmw_connected_drive/manifest.json | 3 +-
.../bmw_connected_drive/strings.json | 6 +-
requirements_all.txt | 2 +-
requirements_test_all.txt | 2 +-
6 files changed, 161 insertions(+), 17 deletions(-)
diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py
index 37ff1eb374c794..5a9e8898b6fd18 100644
--- a/homeassistant/components/bmw_connected_drive/config_flow.py
+++ b/homeassistant/components/bmw_connected_drive/config_flow.py
@@ -4,13 +4,22 @@
from collections.abc import Mapping
from typing import Any
+from urllib.parse import parse_qsl
+from aiohttp.web import Request, Response
from bimmer_connected.api.authentication import MyBMWAuthentication
from bimmer_connected.api.regions import get_region_from_name
-from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
+from bimmer_connected.const import HCAPTCHA_SITE_KEYS, Regions
+from bimmer_connected.models import (
+ MyBMWAPIError,
+ MyBMWAuthError,
+ MyBMWCaptchaMissingError,
+)
from httpx import RequestError
import voluptuous as vol
+from homeassistant.components import http
+from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
@@ -23,9 +32,20 @@
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
+from homeassistant.util.ssl import get_default_context
from . import DOMAIN
-from .const import CONF_ALLOWED_REGIONS, CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN
+from .const import (
+ CONF_ALLOWED_REGIONS,
+ CONF_CAPTCHA_TOKEN,
+ CONF_GCID,
+ CONF_READ_ONLY,
+ CONF_REFRESH_TOKEN,
+ TEMPLATE_HCAPTCHA,
+)
+
+HEADER_FRONTEND_BASE = "HA-Frontend-Base"
+CAPTCHA_URL = "/auth/bmw-connected-drive/captcha"
DATA_SCHEMA = vol.Schema(
{
@@ -37,7 +57,8 @@
translation_key="regions",
)
),
- }
+ },
+ extra=vol.ALLOW_EXTRA,
)
@@ -50,10 +71,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
data[CONF_USERNAME],
data[CONF_PASSWORD],
get_region_from_name(data[CONF_REGION]),
+ hcaptcha_token=data.get(CONF_CAPTCHA_TOKEN),
+ verify=get_default_context(),
)
try:
await auth.login()
+ except MyBMWCaptchaMissingError as ex:
+ raise MissingCaptcha from ex
except MyBMWAuthError as ex:
raise InvalidAuth from ex
except (MyBMWAPIError, RequestError) as ex:
@@ -73,15 +98,17 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
+ data: dict[str, Any] = {}
+
_existing_entry_data: Mapping[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
- errors: dict[str, str] = {}
+ errors: dict[str, str] = self.data.pop("errors", {})
- if user_input is not None:
+ if user_input is not None and not errors:
unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}"
await self.async_set_unique_id(unique_id)
@@ -90,20 +117,34 @@ async def async_step_user(
else:
self._abort_if_unique_id_configured()
+ # Store user input for later use
+ self.data.update(user_input)
+
+ # North America requires captcha token with external step
+ if get_region_from_name(
+ self.data.get(CONF_REGION) or ""
+ ) == Regions.NORTH_AMERICA and not self.data.get(CONF_CAPTCHA_TOKEN):
+ return await self._async_step_captcha()
+
info = None
try:
- info = await validate_input(self.hass, user_input)
- entry_data = {
- **user_input,
- CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
- CONF_GCID: info.get(CONF_GCID),
- }
+ info = await validate_input(self.hass, self.data)
+ except MissingCaptcha:
+ errors["base"] = "missing_captcha"
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
+ finally:
+ self.data.pop(CONF_CAPTCHA_TOKEN, None)
if info:
+ entry_data = {
+ **self.data,
+ CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
+ CONF_GCID: info.get(CONF_GCID),
+ }
+
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=entry_data
@@ -120,7 +161,7 @@ async def async_step_user(
schema = self.add_suggested_values_to_schema(
DATA_SCHEMA,
- self._existing_entry_data,
+ self._existing_entry_data or self.data,
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
@@ -139,6 +180,42 @@ async def async_step_reconfigure(
self._existing_entry_data = self._get_reconfigure_entry().data
return await self.async_step_user()
+ async def _async_step_captcha(self) -> ConfigFlowResult:
+ """Show captcha form."""
+ self.hass.http.register_view(BmwCaptchaView)
+ if (req := http.current_request.get()) is None:
+ raise RuntimeError("No current request in context")
+ if (hass_url := req.headers.get(HEADER_FRONTEND_BASE)) is None:
+ raise RuntimeError("No header in request")
+
+ forward_url = f"{hass_url}{CAPTCHA_URL}?flow_id={self.flow_id}®ion={self.data[CONF_REGION]}"
+ return self.async_external_step(step_id="obtain_captcha", url=forward_url)
+
+ async def async_step_obtain_captcha(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Obtain token after external auth completed."""
+
+ if user_input and (captcha_token := user_input.get(CONF_CAPTCHA_TOKEN)):
+ self.data[CONF_CAPTCHA_TOKEN] = captcha_token
+ else:
+ self.data["errors"] = {"base": "missing_captcha"}
+ return self.async_external_step_done(next_step_id="obtain_captcha_done")
+
+ async def async_step_obtain_captcha_done(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Finalize external step and forward back to step_user."""
+
+ return await self.async_step_user(user_input=self.data)
+
+ # async def async_step_obtain_captcha_missing(
+ # self, user_input: dict[str, Any] | None = None
+ # ) -> ConfigFlowResult:
+ # """Abort if captcha is missing."""
+
+ # return self.async_abort(reason="missing_captcha")
+
@staticmethod
@callback
def async_get_options_flow(
@@ -192,3 +269,49 @@ class CannotConnect(HomeAssistantError):
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
+
+
+class MissingCaptcha(HomeAssistantError):
+ """Error to indicate the captcha token is missing."""
+
+
+class BmwCaptchaView(HomeAssistantView):
+ """Generate views for hcaptcha."""
+
+ url = CAPTCHA_URL
+ name = "auth:bmw-connected-drive:captcha:form"
+ requires_auth = False
+
+ async def get(
+ self,
+ request: Request,
+ ) -> Response:
+ """Form to display hcaptcha token."""
+ return_url = f"{CAPTCHA_URL}?flow_id={request.query['flow_id']}®ion={request.query['region']}"
+ return Response(
+ text=TEMPLATE_HCAPTCHA.format(
+ return_url=return_url,
+ sitekey=HCAPTCHA_SITE_KEYS[
+ get_region_from_name(request.query["region"])
+ ],
+ ),
+ content_type="text/html",
+ )
+
+ async def post(
+ self,
+ request: Request,
+ ) -> Response:
+ """Retrieve hcaptcha token and close window."""
+ hass = request.app[KEY_HASS]
+
+ form_data = dict(parse_qsl(await request.text()))
+ await hass.config_entries.flow.async_configure(
+ flow_id=request.query["flow_id"],
+ user_input={CONF_CAPTCHA_TOKEN: form_data.get("h-captcha-response")},
+ )
+
+ return Response(
+ headers={"content-type": "text/html"},
+ text="Success! This window can be closed",
+ )
diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py
index 98d4acbfc91d86..aec99ea30089b0 100644
--- a/homeassistant/components/bmw_connected_drive/const.py
+++ b/homeassistant/components/bmw_connected_drive/const.py
@@ -12,6 +12,7 @@
CONF_ACCOUNT = "account"
CONF_REFRESH_TOKEN = "refresh_token"
CONF_GCID = "gcid"
+CONF_CAPTCHA_TOKEN = "hcaptcha_token"
DATA_HASS_CONFIG = "hass_config"
@@ -27,3 +28,20 @@
"north_america": 600,
"rest_of_world": 300,
}
+
+TEMPLATE_HCAPTCHA = """
+
+
+
+ hCaptcha
+
+
+
+
+
+"""
diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json
index 6bc9027ac19983..f438d9b2c6808c 100644
--- a/homeassistant/components/bmw_connected_drive/manifest.json
+++ b/homeassistant/components/bmw_connected_drive/manifest.json
@@ -3,9 +3,10 @@
"name": "BMW Connected Drive",
"codeowners": ["@gerard33", "@rikroe"],
"config_flow": true,
+ "dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
"quality_scale": "platinum",
- "requirements": ["bimmer-connected[china]==0.16.3"]
+ "requirements": ["bimmer-connected[china]==0.16.4.0b2"]
}
diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json
index fed71f85e3552e..2708914aa77fcb 100644
--- a/homeassistant/components/bmw_connected_drive/strings.json
+++ b/homeassistant/components/bmw_connected_drive/strings.json
@@ -11,13 +11,15 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "missing_captcha": "Captcha validation missing. Please try again."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
- "account_mismatch": "Username and region are not allowed to change"
+ "account_mismatch": "Username and region are not allowed to change",
+ "missing_captcha": "Captcha validation missing. Please try again."
}
},
"options": {
diff --git a/requirements_all.txt b/requirements_all.txt
index b684846a66a3c0..f82cf86ed1610b 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -572,7 +572,7 @@ beautifulsoup4==4.12.3
# beewi-smartclim==0.0.10
# homeassistant.components.bmw_connected_drive
-bimmer-connected[china]==0.16.3
+bimmer-connected[china]==0.16.4.0b2
# homeassistant.components.bizkaibus
bizkaibus==0.1.1
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index f06860ab66ecea..0ed672528e7ddc 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -506,7 +506,7 @@ base36==0.1.1
beautifulsoup4==4.12.3
# homeassistant.components.bmw_connected_drive
-bimmer-connected[china]==0.16.3
+bimmer-connected[china]==0.16.4.0b2
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
From 83720902d3cdb6938b6962aa1607e2c1727e3832 Mon Sep 17 00:00:00 2001
From: Richard <42204099+rikroe@users.noreply.github.com>
Date: Sat, 2 Nov 2024 00:46:34 +0100
Subject: [PATCH 2/5] Add/fix tests
---
.../bmw_connected_drive/__init__.py | 4 +-
.../snapshots/test_diagnostics.ambr | 6 +-
.../bmw_connected_drive/test_config_flow.py | 215 +++++++++++++++++-
3 files changed, 218 insertions(+), 7 deletions(-)
diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py
index 4d280a1d0e5c8d..35eb96ffec032b 100644
--- a/tests/components/bmw_connected_drive/__init__.py
+++ b/tests/components/bmw_connected_drive/__init__.py
@@ -24,8 +24,8 @@
CONF_PASSWORD: "p4ssw0rd",
CONF_REGION: "rest_of_world",
}
-FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN"
-FIXTURE_GCID = "SOME_GCID"
+FIXTURE_REFRESH_TOKEN = "another_token_string"
+FIXTURE_GCID = "DUMMY"
FIXTURE_CONFIG_ENTRY = {
"entry_id": "1",
diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr
index 81ef1220069763..b87da22a332eef 100644
--- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr
+++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr
@@ -4833,7 +4833,7 @@
}),
]),
'info': dict({
- 'gcid': 'SOME_GCID',
+ 'gcid': 'DUMMY',
'password': '**REDACTED**',
'refresh_token': '**REDACTED**',
'region': 'rest_of_world',
@@ -7202,7 +7202,7 @@
}),
]),
'info': dict({
- 'gcid': 'SOME_GCID',
+ 'gcid': 'DUMMY',
'password': '**REDACTED**',
'refresh_token': '**REDACTED**',
'region': 'rest_of_world',
@@ -8925,7 +8925,7 @@
}),
]),
'info': dict({
- 'gcid': 'SOME_GCID',
+ 'gcid': 'DUMMY',
'password': '**REDACTED**',
'refresh_token': '**REDACTED**',
'region': 'rest_of_world',
diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py
index 9d4d15703f271b..cdc961ce06066c 100644
--- a/tests/components/bmw_connected_drive/test_config_flow.py
+++ b/tests/components/bmw_connected_drive/test_config_flow.py
@@ -1,15 +1,22 @@
"""Test the for the BMW Connected Drive config flow."""
from copy import deepcopy
+from http import HTTPStatus
from unittest.mock import patch
from bimmer_connected.api.authentication import MyBMWAuthentication
-from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
+from bimmer_connected.models import (
+ MyBMWAPIError,
+ MyBMWAuthError,
+ MyBMWCaptchaMissingError,
+)
from httpx import RequestError
+import pytest
from homeassistant import config_entries
-from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN
+from homeassistant.components.bmw_connected_drive.config_flow import CAPTCHA_URL, DOMAIN
from homeassistant.components.bmw_connected_drive.const import (
+ CONF_CAPTCHA_TOKEN,
CONF_READ_ONLY,
CONF_REFRESH_TOKEN,
)
@@ -25,6 +32,7 @@
)
from tests.common import MockConfigEntry
+from tests.typing import ClientSessionGenerator
FIXTURE_COMPLETE_ENTRY = FIXTURE_CONFIG_ENTRY["data"]
FIXTURE_IMPORT_ENTRY = {**FIXTURE_USER_INPUT, CONF_REFRESH_TOKEN: None}
@@ -311,3 +319,206 @@ async def test_reconfigure_unique_id_abort(hass: HomeAssistant) -> None:
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "account_mismatch"
assert config_entry.data == FIXTURE_COMPLETE_ENTRY
+
+
+@pytest.mark.usefixtures("current_request_with_host")
+@pytest.mark.usefixtures("bmw_fixture")
+async def test_captcha_flow_success(hass: HomeAssistant) -> None:
+ """Test the external flow with captcha."""
+
+ TEST_REGION = "north_america"
+
+ # Start flow and open form
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ # Add login data
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION},
+ )
+ assert result["type"] is FlowResultType.EXTERNAL_STEP
+
+ # External step with hcaptcha return value
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_CAPTCHA_TOKEN: "token"}
+ )
+ assert result["type"] is FlowResultType.EXTERNAL_STEP_DONE
+
+ # Validation successful, create entry
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["data"] == (FIXTURE_COMPLETE_ENTRY | {CONF_REGION: TEST_REGION})
+
+
+@pytest.mark.usefixtures("current_request_with_host")
+@pytest.mark.usefixtures("bmw_fixture")
+async def test_captcha_flow_missing_and_success(hass: HomeAssistant) -> None:
+ """Test the external flow with captcha failing once and succeeding the second time."""
+
+ TEST_REGION = "north_america"
+
+ # Start flow and open form
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ # Add login data
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION},
+ )
+ assert result["type"] is FlowResultType.EXTERNAL_STEP
+
+ # External step without hcaptcha return value
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ assert result["type"] is FlowResultType.EXTERNAL_STEP_DONE
+
+ # Validation successful, create entry
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+ assert result["errors"]["base"] == "missing_captcha"
+
+ # Second try, add login data
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION},
+ )
+ assert result["type"] is FlowResultType.EXTERNAL_STEP
+
+ # External step with hcaptcha return value
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_CAPTCHA_TOKEN: "token"}
+ )
+ assert result["type"] is FlowResultType.EXTERNAL_STEP_DONE
+
+ # Validation successful, create entry
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+ assert result["data"] == (FIXTURE_COMPLETE_ENTRY | {CONF_REGION: TEST_REGION})
+
+
+@pytest.mark.usefixtures("current_request_with_host")
+@pytest.mark.usefixtures("bmw_fixture")
+async def test_captcha_flow_not_set(hass: HomeAssistant) -> None:
+ """Test the external flow with captcha failing once and succeeding the second time."""
+
+ TEST_REGION = "north_america"
+
+ # Start flow and open form
+ # Start flow and open form
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ # Add login data
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION},
+ )
+ assert result["type"] is FlowResultType.EXTERNAL_STEP
+
+ # External step with hcaptcha return value
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={CONF_CAPTCHA_TOKEN: "token"}
+ )
+ assert result["type"] is FlowResultType.EXTERNAL_STEP_DONE
+
+ with patch(
+ "bimmer_connected.api.authentication.MyBMWAuthentication._login_row_na",
+ side_effect=MyBMWCaptchaMissingError(
+ "Missing hCaptcha token for North America login"
+ ),
+ ):
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ assert result["errors"]["base"] == "missing_captcha"
+
+
+@pytest.mark.usefixtures("current_request_with_host")
+@pytest.mark.usefixtures("bmw_fixture")
+async def test_captcha_flow_webviews(
+ hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
+) -> None:
+ """Test the external flow webviews."""
+
+ TEST_REGION = "north_america"
+
+ # Start flow and open form
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ # Add login data
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION},
+ )
+ assert result["type"] is FlowResultType.EXTERNAL_STEP
+
+ client = await hass_client_no_auth()
+ forward_url = f"{CAPTCHA_URL}?flow_id={result['flow_id']}®ion={TEST_REGION}"
+
+ resp = await client.get(forward_url)
+ assert resp.status == HTTPStatus.OK
+
+ resp = await client.post(forward_url, data={CONF_CAPTCHA_TOKEN: "token"})
+ assert resp.status == HTTPStatus.OK
+
+
+async def test_captcha_flow_client_request_missing(hass: HomeAssistant) -> None:
+ """Test when client headers are not set properly."""
+ TEST_REGION = "north_america"
+
+ # Start flow and open form
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ with (
+ pytest.raises(RuntimeError),
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION},
+ )
+
+
+@pytest.mark.usefixtures("current_request_with_host")
+async def test_captcha_flow_client_header_issues(hass: HomeAssistant) -> None:
+ """Test when client headers are not set properly."""
+
+ class MockRequest:
+ headers = {}
+
+ TEST_REGION = "north_america"
+
+ # Start flow and open form
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "user"
+
+ with (
+ patch(
+ "homeassistant.components.http.current_request.get",
+ return_value=MockRequest(),
+ ),
+ pytest.raises(RuntimeError),
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION},
+ )
From 5b01b8976154d55f73bff73b0e931fe85e303cbd Mon Sep 17 00:00:00 2001
From: Richard <42204099+rikroe@users.noreply.github.com>
Date: Sat, 2 Nov 2024 09:25:02 +0100
Subject: [PATCH 3/5] Raise reauth flow with captcha-specific error message
---
.../bmw_connected_drive/coordinator.py | 13 +++++-
.../bmw_connected_drive/strings.json | 3 ++
.../bmw_connected_drive/test_coordinator.py | 43 ++++++++++++++++++-
3 files changed, 57 insertions(+), 2 deletions(-)
diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py
index 992e7dea6b263c..d38b7ffacc2a7c 100644
--- a/homeassistant/components/bmw_connected_drive/coordinator.py
+++ b/homeassistant/components/bmw_connected_drive/coordinator.py
@@ -7,7 +7,12 @@
from bimmer_connected.account import MyBMWAccount
from bimmer_connected.api.regions import get_region_from_name
-from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError
+from bimmer_connected.models import (
+ GPSPosition,
+ MyBMWAPIError,
+ MyBMWAuthError,
+ MyBMWCaptchaMissingError,
+)
from httpx import RequestError
from homeassistant.config_entries import ConfigEntry
@@ -61,6 +66,12 @@ async def _async_update_data(self) -> None:
try:
await self.account.get_vehicles()
+ except MyBMWCaptchaMissingError as err:
+ # If a captcha is required (user/password login flow), always trigger the reauth flow
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="missing_captcha",
+ ) from err
except MyBMWAuthError as err:
# Allow one retry interval before raising AuthFailed to avoid flaky API issues
if self.last_update_success:
diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json
index 2708914aa77fcb..ce1facb5d38ef5 100644
--- a/homeassistant/components/bmw_connected_drive/strings.json
+++ b/homeassistant/components/bmw_connected_drive/strings.json
@@ -202,6 +202,9 @@
"exceptions": {
"invalid_poi": {
"message": "Invalid data for point of interest: {poi_exception}"
+ },
+ "missing_captcha": {
+ "message": "Captcha requried. Please prove you are human"
}
}
}
diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py
index b0f507bbfc2b29..774a85eb6da03c 100644
--- a/tests/components/bmw_connected_drive/test_coordinator.py
+++ b/tests/components/bmw_connected_drive/test_coordinator.py
@@ -1,13 +1,19 @@
"""Test BMW coordinator."""
+from copy import deepcopy
from datetime import timedelta
from unittest.mock import patch
-from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
+from bimmer_connected.models import (
+ MyBMWAPIError,
+ MyBMWAuthError,
+ MyBMWCaptchaMissingError,
+)
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN
+from homeassistant.const import CONF_REGION
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import issue_registry as ir
@@ -122,3 +128,38 @@ async def test_init_reauth(
f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}",
)
assert reauth_issue.active is True
+
+
+@pytest.mark.usefixtures("bmw_fixture")
+async def test_captcha_reauth(
+ hass: HomeAssistant,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test the reauth form."""
+ TEST_REGION = "north_america"
+
+ config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY)
+ config_entry_fixure["data"][CONF_REGION] = TEST_REGION
+ config_entry = MockConfigEntry(**config_entry_fixure)
+ config_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ coordinator = config_entry.runtime_data.coordinator
+
+ assert coordinator.last_update_success is True
+
+ freezer.tick(timedelta(minutes=10, seconds=1))
+ with patch(
+ "bimmer_connected.account.MyBMWAccount.get_vehicles",
+ side_effect=MyBMWCaptchaMissingError(
+ "Missing hCaptcha token for North America login"
+ ),
+ ):
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert coordinator.last_update_success is False
+ assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True
+ assert coordinator.last_exception.translation_key == "missing_captcha"
From 37aa8fd9f6c4b160e0ea971061f1dcb2b28a1eb5 Mon Sep 17 00:00:00 2001
From: Richard <42204099+rikroe@users.noreply.github.com>
Date: Sat, 2 Nov 2024 09:35:47 +0100
Subject: [PATCH 4/5] Cleanup
---
.../bmw_connected_drive/config_flow.py | 43 ++++++++-----------
.../bmw_connected_drive/strings.json | 5 +--
2 files changed, 20 insertions(+), 28 deletions(-)
diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py
index 5a9e8898b6fd18..6371563e8ebf9d 100644
--- a/homeassistant/components/bmw_connected_drive/config_flow.py
+++ b/homeassistant/components/bmw_connected_drive/config_flow.py
@@ -124,7 +124,7 @@ async def async_step_user(
if get_region_from_name(
self.data.get(CONF_REGION) or ""
) == Regions.NORTH_AMERICA and not self.data.get(CONF_CAPTCHA_TOKEN):
- return await self._async_step_captcha()
+ return await self._async_step_captcha_show()
info = None
try:
@@ -180,7 +180,7 @@ async def async_step_reconfigure(
self._existing_entry_data = self._get_reconfigure_entry().data
return await self.async_step_user()
- async def _async_step_captcha(self) -> ConfigFlowResult:
+ async def _async_step_captcha_show(self) -> ConfigFlowResult:
"""Show captcha form."""
self.hass.http.register_view(BmwCaptchaView)
if (req := http.current_request.get()) is None:
@@ -189,9 +189,9 @@ async def _async_step_captcha(self) -> ConfigFlowResult:
raise RuntimeError("No header in request")
forward_url = f"{hass_url}{CAPTCHA_URL}?flow_id={self.flow_id}®ion={self.data[CONF_REGION]}"
- return self.async_external_step(step_id="obtain_captcha", url=forward_url)
+ return self.async_external_step(step_id="captcha_retrieve", url=forward_url)
- async def async_step_obtain_captcha(
+ async def async_step_captcha_retrieve(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Obtain token after external auth completed."""
@@ -200,22 +200,15 @@ async def async_step_obtain_captcha(
self.data[CONF_CAPTCHA_TOKEN] = captcha_token
else:
self.data["errors"] = {"base": "missing_captcha"}
- return self.async_external_step_done(next_step_id="obtain_captcha_done")
+ return self.async_external_step_done(next_step_id="captcha_done")
- async def async_step_obtain_captcha_done(
+ async def async_step_captcha_done(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Finalize external step and forward back to step_user."""
return await self.async_step_user(user_input=self.data)
- # async def async_step_obtain_captcha_missing(
- # self, user_input: dict[str, Any] | None = None
- # ) -> ConfigFlowResult:
- # """Abort if captcha is missing."""
-
- # return self.async_abort(reason="missing_captcha")
-
@staticmethod
@callback
def async_get_options_flow(
@@ -263,18 +256,6 @@ async def async_step_account_options(
)
-class CannotConnect(HomeAssistantError):
- """Error to indicate we cannot connect."""
-
-
-class InvalidAuth(HomeAssistantError):
- """Error to indicate there is invalid auth."""
-
-
-class MissingCaptcha(HomeAssistantError):
- """Error to indicate the captcha token is missing."""
-
-
class BmwCaptchaView(HomeAssistantView):
"""Generate views for hcaptcha."""
@@ -315,3 +296,15 @@ async def post(
headers={"content-type": "text/html"},
text="Success! This window can be closed",
)
+
+
+class CannotConnect(HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(HomeAssistantError):
+ """Error to indicate there is invalid auth."""
+
+
+class MissingCaptcha(HomeAssistantError):
+ """Error to indicate the captcha token is missing."""
diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json
index ce1facb5d38ef5..f8d81195373c00 100644
--- a/homeassistant/components/bmw_connected_drive/strings.json
+++ b/homeassistant/components/bmw_connected_drive/strings.json
@@ -18,8 +18,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
- "account_mismatch": "Username and region are not allowed to change",
- "missing_captcha": "Captcha validation missing. Please try again."
+ "account_mismatch": "Username and region are not allowed to change"
}
},
"options": {
@@ -204,7 +203,7 @@
"message": "Invalid data for point of interest: {poi_exception}"
},
"missing_captcha": {
- "message": "Captcha requried. Please prove you are human"
+ "message": "Login requires captcha validation"
}
}
}
From 411925066eb6c572483755a619e29bceed3d23d5 Mon Sep 17 00:00:00 2001
From: Richard <42204099+rikroe@users.noreply.github.com>
Date: Sun, 3 Nov 2024 10:27:10 +0100
Subject: [PATCH 5/5] Bump bimmer_connected to 0.16.4
---
homeassistant/components/bmw_connected_drive/manifest.json | 2 +-
requirements_all.txt | 2 +-
requirements_test_all.txt | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json
index f438d9b2c6808c..b11c4c9cfadf35 100644
--- a/homeassistant/components/bmw_connected_drive/manifest.json
+++ b/homeassistant/components/bmw_connected_drive/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
"quality_scale": "platinum",
- "requirements": ["bimmer-connected[china]==0.16.4.0b2"]
+ "requirements": ["bimmer-connected[china]==0.16.4"]
}
diff --git a/requirements_all.txt b/requirements_all.txt
index f82cf86ed1610b..de8e00b4feeda8 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -572,7 +572,7 @@ beautifulsoup4==4.12.3
# beewi-smartclim==0.0.10
# homeassistant.components.bmw_connected_drive
-bimmer-connected[china]==0.16.4.0b2
+bimmer-connected[china]==0.16.4
# homeassistant.components.bizkaibus
bizkaibus==0.1.1
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 0ed672528e7ddc..3062616cdc5451 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -506,7 +506,7 @@ base36==0.1.1
beautifulsoup4==4.12.3
# homeassistant.components.bmw_connected_drive
-bimmer-connected[china]==0.16.4.0b2
+bimmer-connected[china]==0.16.4
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome