1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Fix Tesla Fleet partner registration to use all regions (#162525)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Brett Adams
2026-02-10 00:33:05 +10:00
committed by GitHub
parent 9ece327881
commit 5c4d9f4ca4
2 changed files with 230 additions and 61 deletions

View File

@@ -10,11 +10,7 @@ from typing import Any, cast
import jwt
from tesla_fleet_api import TeslaFleetApi
from tesla_fleet_api.const import SERVERS
from tesla_fleet_api.exceptions import (
InvalidResponse,
PreconditionFailed,
TeslaFleetError,
)
from tesla_fleet_api.exceptions import PreconditionFailed, TeslaFleetError
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
@@ -41,12 +37,9 @@ class OAuth2FlowHandler(
"""Initialize config flow."""
super().__init__()
self.domain: str | None = None
self.registration_status: dict[str, bool] = {}
self.tesla_apis: dict[str, TeslaFleetApi] = {}
self.failed_regions: list[str] = []
self.data: dict[str, Any] = {}
self.uid: str | None = None
self.api: TeslaFleetApi | None = None
self.apis: list[TeslaFleetApi] = []
@property
def logger(self) -> logging.Logger:
@@ -64,7 +57,6 @@ class OAuth2FlowHandler(
self.data = data
self.uid = token["sub"]
server = SERVERS[token["ou_code"].lower()]
await self.async_set_unique_id(self.uid)
if self.source == SOURCE_REAUTH:
@@ -74,24 +66,28 @@ class OAuth2FlowHandler(
)
self._abort_if_unique_id_configured()
# OAuth done, setup a Partner API connection
# OAuth done, setup Partner API connections for all regions
implementation = cast(TeslaUserImplementation, self.flow_impl)
session = async_get_clientsession(self.hass)
self.api = TeslaFleetApi(
access_token="",
session=session,
server=server,
partner_scope=True,
charging_scope=False,
energy_scope=False,
user_scope=False,
vehicle_scope=False,
)
await self.api.get_private_key(self.hass.config.path("tesla_fleet.key"))
await self.api.partner_login(
implementation.client_id, implementation.client_secret
)
for region, server_url in SERVERS.items():
if region == "cn":
continue
api = TeslaFleetApi(
session=session,
access_token="",
server=server_url,
partner_scope=True,
charging_scope=False,
energy_scope=False,
user_scope=False,
vehicle_scope=False,
)
await api.get_private_key(self.hass.config.path("tesla_fleet.key"))
await api.partner_login(
implementation.client_id, implementation.client_secret
)
self.apis.append(api)
return await self.async_step_domain_input()
@@ -130,44 +126,67 @@ class OAuth2FlowHandler(
async def async_step_domain_registration(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle domain registration for both regions."""
"""Handle domain registration for all regions."""
assert self.api
assert self.api.private_key
assert self.apis
assert self.apis[0].private_key
assert self.domain
errors = {}
errors: dict[str, str] = {}
description_placeholders = {
"public_key_url": f"https://{self.domain}/.well-known/appspecific/com.tesla.3p.public-key.pem",
"pem": self.api.public_pem,
"pem": self.apis[0].public_pem,
}
try:
register_response = await self.api.partner.register(self.domain)
except PreconditionFailed:
return await self.async_step_domain_input(
errors={CONF_DOMAIN: "precondition_failed"}
)
except InvalidResponse:
successful_response: dict[str, Any] | None = None
failed_regions: list[str] = []
for api in self.apis:
try:
register_response = await api.partner.register(self.domain)
except PreconditionFailed:
return await self.async_step_domain_input(
errors={CONF_DOMAIN: "precondition_failed"}
)
except TeslaFleetError as e:
LOGGER.warning(
"Partner registration failed for %s: %s",
api.server,
e.message,
)
failed_regions.append(api.server or "unknown")
else:
if successful_response is None:
successful_response = register_response
if successful_response is None:
errors["base"] = "invalid_response"
except TeslaFleetError as e:
errors["base"] = "unknown_error"
description_placeholders["error"] = e.message
else:
# Get public key from response
registered_public_key = register_response.get("response", {}).get(
"public_key"
return self.async_show_form(
step_id="domain_registration",
description_placeholders=description_placeholders,
errors=errors,
)
if not registered_public_key:
errors["base"] = "public_key_not_found"
elif (
registered_public_key.lower()
!= self.api.public_uncompressed_point.lower()
):
errors["base"] = "public_key_mismatch"
else:
return await self.async_step_registration_complete()
if failed_regions:
LOGGER.warning(
"Partner registration succeeded on some regions but failed on: %s",
", ".join(failed_regions),
)
# Verify public key from the successful response
registered_public_key = successful_response.get("response", {}).get(
"public_key"
)
if not registered_public_key:
errors["base"] = "public_key_not_found"
elif (
registered_public_key.lower()
!= self.apis[0].public_uncompressed_point.lower()
):
errors["base"] = "public_key_mismatch"
else:
return await self.async_step_registration_complete()
return self.async_show_form(
step_id="domain_registration",

View File

@@ -251,7 +251,7 @@ async def test_domain_input_invalid_domain(
("side_effect", "expected_error"),
[
(InvalidResponse, "invalid_response"),
(TeslaFleetError("Custom error"), "unknown_error"),
(TeslaFleetError("Custom error"), "invalid_response"),
],
)
@pytest.mark.usefixtures("current_request_with_host")
@@ -307,12 +307,9 @@ async def test_domain_registration_errors(
result = await hass.config_entries.flow.async_configure(result["flow_id"])
# Enter domain - this should fail and stay on domain_registration
with patch(
"homeassistant.helpers.translation.async_get_translations", return_value={}
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_DOMAIN: "example.com"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_DOMAIN: "example.com"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "domain_registration"
assert result["errors"] == {"base": expected_error}
@@ -497,6 +494,159 @@ async def test_domain_registration_public_key_mismatch(
assert result["errors"] == {"base": "public_key_mismatch"}
@pytest.mark.usefixtures("current_request_with_host")
async def test_domain_registration_partial_failure(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
access_token: str,
mock_private_key,
) -> None:
"""Test domain registration succeeds when one region fails."""
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": access_token,
"type": "Bearer",
"expires_in": 60,
},
)
public_key = "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122"
# Create two separate mocks for NA and EU
mock_api_na = AsyncMock()
mock_api_na.private_key = mock_private_key
mock_api_na.get_private_key = AsyncMock()
mock_api_na.partner_login = AsyncMock()
mock_api_na.public_pem = "test_pem"
mock_api_na.public_uncompressed_point = public_key
mock_api_na.partner.register.return_value = {"response": {"public_key": public_key}}
mock_api_eu = AsyncMock()
mock_api_eu.private_key = mock_private_key
mock_api_eu.get_private_key = AsyncMock()
mock_api_eu.partner_login = AsyncMock()
mock_api_eu.public_pem = "test_pem"
mock_api_eu.public_uncompressed_point = public_key
mock_api_eu.server = "https://fleet-api.prd.eu.vn.cloud.tesla.com"
mock_api_eu.partner.register.side_effect = TeslaFleetError("EU registration failed")
with (
patch(
"homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi",
side_effect=[mock_api_na, mock_api_eu],
),
patch(
"homeassistant.components.tesla_fleet.async_setup_entry", return_value=True
),
):
# Complete OAuth
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "domain_input"
# Enter domain - NA succeeds, EU fails, should still proceed
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_DOMAIN: "example.com"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "registration_complete"
# Complete flow
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == UNIQUE_ID
@pytest.mark.usefixtures("current_request_with_host")
async def test_domain_registration_all_regions_fail(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
access_token: str,
mock_private_key,
) -> None:
"""Test domain registration fails when all regions fail."""
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": access_token,
"type": "Bearer",
"expires_in": 60,
},
)
mock_api_na = AsyncMock()
mock_api_na.private_key = mock_private_key
mock_api_na.get_private_key = AsyncMock()
mock_api_na.partner_login = AsyncMock()
mock_api_na.public_pem = "test_pem"
mock_api_na.public_uncompressed_point = "test_point"
mock_api_na.server = "https://fleet-api.prd.na.vn.cloud.tesla.com"
mock_api_na.partner.register.side_effect = TeslaFleetError("NA registration failed")
mock_api_eu = AsyncMock()
mock_api_eu.private_key = mock_private_key
mock_api_eu.get_private_key = AsyncMock()
mock_api_eu.partner_login = AsyncMock()
mock_api_eu.public_pem = "test_pem"
mock_api_eu.public_uncompressed_point = "test_point"
mock_api_eu.server = "https://fleet-api.prd.eu.vn.cloud.tesla.com"
mock_api_eu.partner.register.side_effect = TeslaFleetError("EU registration failed")
with patch(
"homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi",
side_effect=[mock_api_na, mock_api_eu],
):
# Complete OAuth
result = await hass.config_entries.flow.async_configure(result["flow_id"])
# Enter domain - both regions fail
with patch(
"homeassistant.helpers.translation.async_get_translations", return_value={}
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_DOMAIN: "example.com"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "domain_registration"
assert result["errors"] == {"base": "invalid_response"}
@pytest.mark.usefixtures("current_request_with_host")
async def test_registration_complete_no_domain(
hass: HomeAssistant,