From 7688c367cc6eaffeda8be5ac24eec5436fa54408 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Mon, 11 Aug 2025 07:58:36 -0700 Subject: [PATCH] Remove coinbase v2 API support (#148387) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/coinbase/__init__.py | 78 ++----- .../components/coinbase/config_flow.py | 82 +++++-- .../components/coinbase/manifest.json | 2 +- homeassistant/components/coinbase/sensor.py | 14 +- .../components/coinbase/strings.json | 11 +- requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/coinbase/common.py | 30 +-- tests/components/coinbase/test_config_flow.py | 208 +++++++++--------- tests/components/coinbase/test_diagnostics.py | 14 +- tests/components/coinbase/test_init.py | 92 ++++++-- 11 files changed, 291 insertions(+), 246 deletions(-) diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index 317759f820d..adb6dc48c9c 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -7,12 +7,11 @@ import logging from coinbase.rest import RESTClient from coinbase.rest.rest_base import HTTPError -from coinbase.wallet.client import Client as LegacyClient -from coinbase.wallet.error import AuthenticationError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from homeassistant.util import Throttle @@ -20,9 +19,7 @@ from .const import ( ACCOUNT_IS_VAULT, API_ACCOUNT_AMOUNT, API_ACCOUNT_AVALIABLE, - API_ACCOUNT_BALANCE, API_ACCOUNT_CURRENCY, - API_ACCOUNT_CURRENCY_CODE, API_ACCOUNT_HOLD, API_ACCOUNT_ID, API_ACCOUNT_NAME, @@ -31,7 +28,6 @@ from .const import ( API_DATA, API_RATES_CURRENCY, API_RESOURCE_TYPE, - API_TYPE_VAULT, API_V3_ACCOUNT_ID, API_V3_TYPE_VAULT, CONF_CURRENCIES, @@ -68,16 +64,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) -> def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData: """Create and update a Coinbase Data instance.""" + + # Check if user is using deprecated v2 API credentials if "organizations" not in entry.data[CONF_API_KEY]: - client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN]) - version = "v2" - else: - client = RESTClient( - api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN] + # Trigger reauthentication to ask user for v3 credentials + raise ConfigEntryAuthFailed( + "Your Coinbase API key appears to be for the deprecated v2 API. " + "Please reconfigure with a new API key created for the v3 API. " + "Visit https://www.coinbase.com/developer-platform to create new credentials." ) - version = "v3" + + client = RESTClient( + api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN] + ) base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD") - instance = CoinbaseData(client, base_rate, version) + instance = CoinbaseData(client, base_rate) instance.update() return instance @@ -105,31 +106,9 @@ async def update_listener( registry.async_remove(entity.entity_id) -def get_accounts(client, version): +def get_accounts(client): """Handle paginated accounts.""" response = client.get_accounts() - if version == "v2": - accounts = response[API_DATA] - next_starting_after = response.pagination.next_starting_after - - while next_starting_after: - response = client.get_accounts(starting_after=next_starting_after) - accounts += response[API_DATA] - next_starting_after = response.pagination.next_starting_after - - return [ - { - API_ACCOUNT_ID: account[API_ACCOUNT_ID], - API_ACCOUNT_NAME: account[API_ACCOUNT_NAME], - API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY][ - API_ACCOUNT_CURRENCY_CODE - ], - API_ACCOUNT_AMOUNT: account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT], - ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_TYPE_VAULT, - } - for account in accounts - ] - accounts = response[API_ACCOUNTS] while response["has_next"]: response = client.get_accounts(cursor=response["cursor"]) @@ -153,37 +132,28 @@ def get_accounts(client, version): class CoinbaseData: """Get the latest data and update the states.""" - def __init__(self, client, exchange_base, version): + def __init__(self, client, exchange_base): """Init the coinbase data object.""" self.client = client self.accounts = None self.exchange_base = exchange_base self.exchange_rates = None - if version == "v2": - self.user_id = self.client.get_current_user()[API_ACCOUNT_ID] - else: - self.user_id = ( - "v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID] - ) - self.api_version = version + self.user_id = ( + "v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID] + ) @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from coinbase.""" try: - self.accounts = get_accounts(self.client, self.api_version) - if self.api_version == "v2": - self.exchange_rates = self.client.get_exchange_rates( - currency=self.exchange_base - ) - else: - self.exchange_rates = self.client.get( - "/v2/exchange-rates", - params={API_RATES_CURRENCY: self.exchange_base}, - )[API_DATA] - except (AuthenticationError, HTTPError) as coinbase_error: + self.accounts = get_accounts(self.client) + self.exchange_rates = self.client.get( + "/v2/exchange-rates", + params={API_RATES_CURRENCY: self.exchange_base}, + )[API_DATA] + except HTTPError as coinbase_error: _LOGGER.error( "Authentication error connecting to coinbase: %s", coinbase_error ) diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 3234ec29679..e1dad899d2b 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -2,17 +2,16 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from coinbase.rest import RESTClient from coinbase.rest.rest_base import HTTPError -from coinbase.wallet.client import Client as LegacyClient -from coinbase.wallet.error import AuthenticationError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -45,9 +44,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema( def get_user_from_client(api_key, api_token): """Get the user name from Coinbase API credentials.""" - if "organizations" not in api_key: - client = LegacyClient(api_key, api_token) - return client.get_current_user()["name"] client = RESTClient(api_key=api_key, api_secret=api_token) return client.get_portfolios()["portfolios"][0]["name"] @@ -59,7 +55,7 @@ async def validate_api(hass: HomeAssistant, data): user = await hass.async_add_executor_job( get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN] ) - except (AuthenticationError, HTTPError) as error: + except HTTPError as error: if "api key" in str(error) or " 401 Client Error" in str(error): _LOGGER.debug("Coinbase rejected API credentials due to an invalid API key") raise InvalidKey from error @@ -74,8 +70,8 @@ async def validate_api(hass: HomeAssistant, data): raise InvalidAuth from error except ConnectionError as error: raise CannotConnect from error - api_version = "v3" if "organizations" in data[CONF_API_KEY] else "v2" - return {"title": user, "api_version": api_version} + + return {"title": user} async def validate_options( @@ -85,20 +81,17 @@ async def validate_options( client = config_entry.runtime_data.client - accounts = await hass.async_add_executor_job( - get_accounts, client, config_entry.data.get("api_version", "v2") - ) + accounts = await hass.async_add_executor_job(get_accounts, client) accounts_currencies = [ account[API_ACCOUNT_CURRENCY] for account in accounts if not account[ACCOUNT_IS_VAULT] ] - if config_entry.data.get("api_version", "v2") == "v2": - available_rates = await hass.async_add_executor_job(client.get_exchange_rates) - else: - resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates") - available_rates = resp[API_DATA] + + resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates") + available_rates = resp[API_DATA] + if CONF_CURRENCIES in options: for currency in options[CONF_CURRENCIES]: if currency not in accounts_currencies: @@ -117,6 +110,8 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + reauth_entry: CoinbaseConfigEntry + async def async_step_user( self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: @@ -143,12 +138,63 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - user_input[CONF_API_VERSION] = info["api_version"] return self.async_create_entry(title=info["title"], data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication flow.""" + self.reauth_entry = self._get_reauth_entry() + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Handle reauthentication confirmation.""" + errors: dict[str, str] = {} + + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders={ + "account_name": self.reauth_entry.title, + }, + errors=errors, + ) + + try: + await validate_api(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidKey: + errors["base"] = "invalid_auth_key" + except InvalidSecret: + errors["base"] = "invalid_auth_secret" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + self.reauth_entry, + data_updates=user_input, + reason="reauth_successful", + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders={ + "account_name": self.reauth_entry.title, + }, + errors=errors, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/coinbase/manifest.json b/homeassistant/components/coinbase/manifest.json index be632b5e856..fcd48f9e91d 100644 --- a/homeassistant/components/coinbase/manifest.json +++ b/homeassistant/components/coinbase/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/coinbase", "iot_class": "cloud_polling", "loggers": ["coinbase"], - "requirements": ["coinbase==2.1.0", "coinbase-advanced-py==1.2.2"] + "requirements": ["coinbase-advanced-py==1.2.2"] } diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 578877e7d90..f69aed8c386 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -27,7 +27,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) ATTR_NATIVE_BALANCE = "Balance in native currency" -ATTR_API_VERSION = "API Version" CURRENCY_ICONS = { "BTC": "mdi:currency-btc", @@ -71,9 +70,8 @@ async def async_setup_entry( for currency in desired_currencies: _LOGGER.debug( - "Attempting to set up %s account sensor with %s API", + "Attempting to set up %s account sensor", currency, - instance.api_version, ) if currency not in provided_currencies: _LOGGER.warning( @@ -89,9 +87,8 @@ async def async_setup_entry( if CONF_EXCHANGE_RATES in config_entry.options: for rate in config_entry.options[CONF_EXCHANGE_RATES]: _LOGGER.debug( - "Attempting to set up %s account sensor with %s API", + "Attempting to set up %s exchange rate sensor", rate, - instance.api_version, ) entities.append( ExchangeRateSensor( @@ -146,15 +143,13 @@ class AccountSensor(SensorEntity): """Return the state attributes of the sensor.""" return { ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}", - ATTR_API_VERSION: self._coinbase_data.api_version, } def update(self) -> None: """Get the latest state of the sensor.""" _LOGGER.debug( - "Updating %s account sensor with %s API", + "Updating %s account sensor", self._currency, - self._coinbase_data.api_version, ) self._coinbase_data.update() for account in self._coinbase_data.accounts: @@ -210,9 +205,8 @@ class ExchangeRateSensor(SensorEntity): def update(self) -> None: """Get the latest state of the sensor.""" _LOGGER.debug( - "Updating %s rate sensor with %s API", + "Updating %s rate sensor", self._currency, - self._coinbase_data.api_version, ) self._coinbase_data.update() self._attr_native_value = round( diff --git a/homeassistant/components/coinbase/strings.json b/homeassistant/components/coinbase/strings.json index 74510731b7a..b0774baf403 100644 --- a/homeassistant/components/coinbase/strings.json +++ b/homeassistant/components/coinbase/strings.json @@ -8,6 +8,14 @@ "api_key": "[%key:common::config_flow::data::api_key%]", "api_token": "API secret" } + }, + "reauth_confirm": { + "title": "Update Coinbase API credentials", + "description": "Your current Coinbase API key appears to be for the deprecated v2 API. Please reconfigure with a new API key created for the v3 API. Visit https://www.coinbase.com/developer-platform to create new credentials for {account_name}.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "api_token": "API secret" + } } }, "error": { @@ -18,7 +26,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "Successfully updated credentials" } }, "options": { diff --git a/requirements_all.txt b/requirements_all.txt index 1bd2631b36d..517e3f19bde 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -724,9 +724,6 @@ clx-sdk-xms==1.0.0 # homeassistant.components.coinbase coinbase-advanced-py==1.2.2 -# homeassistant.components.coinbase -coinbase==2.1.0 - # homeassistant.scripts.check_config colorlog==6.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f2177be210..f63a164c3f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -633,9 +633,6 @@ caldav==1.6.0 # homeassistant.components.coinbase coinbase-advanced-py==1.2.2 -# homeassistant.components.coinbase -coinbase==2.1.0 - # homeassistant.scripts.check_config colorlog==6.9.0 diff --git a/tests/components/coinbase/common.py b/tests/components/coinbase/common.py index 0a2475ac218..be538c7a42d 100644 --- a/tests/components/coinbase/common.py +++ b/tests/components/coinbase/common.py @@ -5,7 +5,7 @@ from homeassistant.components.coinbase.const import ( CONF_EXCHANGE_RATES, DOMAIN, ) -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant from .const import ( @@ -65,7 +65,7 @@ class MockGetAccountsV3: start = ids.index(cursor) if cursor else 0 has_next = (target_end := start + 2) < len(MOCK_ACCOUNTS_RESPONSE_V3) - end = target_end if has_next else -1 + end = target_end if has_next else len(MOCK_ACCOUNTS_RESPONSE_V3) next_cursor = ids[end] if has_next else ids[-1] self.accounts = { "accounts": MOCK_ACCOUNTS_RESPONSE_V3[start:end], @@ -120,31 +120,6 @@ async def init_mock_coinbase( hass: HomeAssistant, currencies: list[str] | None = None, rates: list[str] | None = None, -) -> MockConfigEntry: - """Init Coinbase integration for testing.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="080272b77a4f80c41b94d7cdc86fd826", - unique_id=None, - title="Test User", - data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, - options={ - CONF_CURRENCIES: currencies or [], - CONF_EXCHANGE_RATES: rates or [], - }, - ) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - return config_entry - - -async def init_mock_coinbase_v3( - hass: HomeAssistant, - currencies: list[str] | None = None, - rates: list[str] | None = None, ) -> MockConfigEntry: """Init Coinbase integration for testing.""" config_entry = MockConfigEntry( @@ -155,7 +130,6 @@ async def init_mock_coinbase_v3( data={ CONF_API_KEY: "organizations/123456", CONF_API_TOKEN: "AbCDeF", - CONF_API_VERSION: "v3", }, options={ CONF_CURRENCIES: currencies or [], diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index aa2c6208e0f..0dc7fa95ffb 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -3,9 +3,8 @@ import logging from unittest.mock import patch -from coinbase.wallet.error import AuthenticationError +from coinbase.rest.rest_base import HTTPError import pytest -from requests.models import Response from homeassistant import config_entries from homeassistant.components.coinbase.const import ( @@ -14,17 +13,14 @@ from homeassistant.components.coinbase.const import ( CONF_EXCHANGE_RATES, DOMAIN, ) -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from .common import ( init_mock_coinbase, - init_mock_coinbase_v3, - mock_get_current_user, mock_get_exchange_rates, mock_get_portfolios, - mocked_get_accounts, mocked_get_accounts_v3, ) from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHANGE_RATE @@ -41,13 +37,13 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), patch( "homeassistant.components.coinbase.async_setup_entry", @@ -61,11 +57,10 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test User" + assert result2["title"] == "Default" assert result2["data"] == { CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF", - CONF_API_VERSION: "v2", } assert len(mock_setup_entry.mock_calls) == 1 @@ -80,16 +75,9 @@ async def test_form_invalid_auth( caplog.set_level(logging.DEBUG) - response = Response() - response.status_code = 401 - api_auth_error_unknown = AuthenticationError( - response, - "authentication_error", - "unknown error", - [{"id": "authentication_error", "message": "unknown error"}], - ) + api_auth_error_unknown = HTTPError("unknown error") with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=api_auth_error_unknown, ): result2 = await hass.config_entries.flow.async_configure( @@ -104,14 +92,9 @@ async def test_form_invalid_auth( assert result2["errors"] == {"base": "invalid_auth"} assert "Coinbase rejected API credentials due to an unknown error" in caplog.text - api_auth_error_key = AuthenticationError( - response, - "authentication_error", - "invalid api key", - [{"id": "authentication_error", "message": "invalid api key"}], - ) + api_auth_error_key = HTTPError("invalid api key") with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=api_auth_error_key, ): result2 = await hass.config_entries.flow.async_configure( @@ -126,14 +109,9 @@ async def test_form_invalid_auth( assert result2["errors"] == {"base": "invalid_auth_key"} assert "Coinbase rejected API credentials due to an invalid API key" in caplog.text - api_auth_error_secret = AuthenticationError( - response, - "authentication_error", - "invalid signature", - [{"id": "authentication_error", "message": "invalid signature"}], - ) + api_auth_error_secret = HTTPError("invalid signature") with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=api_auth_error_secret, ): result2 = await hass.config_entries.flow.async_configure( @@ -158,7 +136,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ) with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=ConnectionError, ): result2 = await hass.config_entries.flow.async_configure( @@ -180,7 +158,7 @@ async def test_form_catch_all_exception(hass: HomeAssistant) -> None: ) with patch( - "coinbase.wallet.client.Client.get_current_user", + "coinbase.rest.RESTClient.get_portfolios", side_effect=Exception, ): result2 = await hass.config_entries.flow.async_configure( @@ -200,13 +178,13 @@ async def test_option_form(hass: HomeAssistant) -> None: with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), patch( "homeassistant.components.coinbase.update_listener" @@ -233,13 +211,13 @@ async def test_form_bad_account_currency(hass: HomeAssistant) -> None: """Test we handle a bad currency option.""" with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) @@ -262,13 +240,13 @@ async def test_form_bad_exchange_rate(hass: HomeAssistant) -> None: """Test we handle a bad exchange rate.""" with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) @@ -290,13 +268,13 @@ async def test_option_catch_all_exception(hass: HomeAssistant) -> None: """Test we handle an unknown exception in the option flow.""" with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) @@ -304,7 +282,7 @@ async def test_option_catch_all_exception(hass: HomeAssistant) -> None: await hass.async_block_till_done() with patch( - "coinbase.wallet.client.Client.get_accounts", + "coinbase.rest.RESTClient.get_accounts", side_effect=Exception, ): result2 = await hass.config_entries.options.async_configure( @@ -320,75 +298,99 @@ async def test_option_catch_all_exception(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_form_v3(hass: HomeAssistant) -> None: - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test reauth flow.""" with ( - patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( "coinbase.rest.RESTClient.get_portfolios", return_value=mock_get_portfolios(), ), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.rest.RESTBase.get", + "coinbase.rest.RESTClient.get", return_value={"data": mock_get_exchange_rates()}, ), + ): + config_entry = await init_mock_coinbase(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # Test successful reauth + with ( patch( - "homeassistant.components.coinbase.async_setup_entry", - return_value=True, - ) as mock_setup_entry, + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), + ), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), + patch( + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, + ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_API_KEY: "organizations/123456", CONF_API_TOKEN: "AbCDeF"}, + { + CONF_API_KEY: "new_key", + CONF_API_TOKEN: "new_secret", + }, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Default" - assert result2["data"] == { - CONF_API_KEY: "organizations/123456", - CONF_API_TOKEN: "AbCDeF", - CONF_API_VERSION: "v3", - } - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "new_key" + assert config_entry.data[CONF_API_TOKEN] == "new_secret" -async def test_option_form_v3(hass: HomeAssistant) -> None: - """Test we handle a good wallet currency option.""" - +async def test_reauth_flow_invalid_auth(hass: HomeAssistant) -> None: + """Test reauth flow with invalid credentials.""" with ( - patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( "coinbase.rest.RESTClient.get_portfolios", return_value=mock_get_portfolios(), ), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.rest.RESTBase.get", + "coinbase.rest.RESTClient.get", return_value={"data": mock_get_exchange_rates()}, ), - patch( - "homeassistant.components.coinbase.update_listener" - ) as mock_update_listener, ): - config_entry = await init_mock_coinbase_v3(hass) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) - await hass.async_block_till_done() - result2 = await hass.config_entries.options.async_configure( + config_entry = await init_mock_coinbase(hass) + + # Start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + + # Test invalid auth during reauth + api_auth_error_key = HTTPError("invalid api key") + with patch( + "coinbase.rest.RESTClient.get_portfolios", + side_effect=api_auth_error_key, + ): + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - CONF_CURRENCIES: [GOOD_CURRENCY], - CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE], - CONF_EXCHANGE_PRECISION: 5, + { + CONF_API_KEY: "bad_key", + CONF_API_TOKEN: "bad_secret", }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - assert len(mock_update_listener.mock_calls) == 1 + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reauth_confirm" + assert result2["errors"] == {"base": "invalid_auth_key"} diff --git a/tests/components/coinbase/test_diagnostics.py b/tests/components/coinbase/test_diagnostics.py index 98936f47e48..5e708756d80 100644 --- a/tests/components/coinbase/test_diagnostics.py +++ b/tests/components/coinbase/test_diagnostics.py @@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant from .common import ( init_mock_coinbase, - mock_get_current_user, mock_get_exchange_rates, - mocked_get_accounts, + mock_get_portfolios, + mocked_get_accounts_v3, ) from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -27,13 +27,13 @@ async def test_entry_diagnostics( with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) diff --git a/tests/components/coinbase/test_init.py b/tests/components/coinbase/test_init.py index 99b6bb4a9bd..7705a4d8e81 100644 --- a/tests/components/coinbase/test_init.py +++ b/tests/components/coinbase/test_init.py @@ -2,6 +2,9 @@ from unittest.mock import patch +import pytest + +from homeassistant.components.coinbase import create_and_update_instance from homeassistant.components.coinbase.const import ( API_TYPE_VAULT, CONF_CURRENCIES, @@ -9,14 +12,16 @@ from homeassistant.components.coinbase.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from .common import ( init_mock_coinbase, - mock_get_current_user, mock_get_exchange_rates, - mocked_get_accounts, + mock_get_portfolios, + mocked_get_accounts_v3, ) from .const import ( GOOD_CURRENCY, @@ -30,16 +35,16 @@ async def test_unload_entry(hass: HomeAssistant) -> None: """Test successful unload of entry.""" with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), patch( - "coinbase.wallet.client.Client.get_accounts", - new=mocked_get_accounts, + "coinbase.rest.RESTClient.get_accounts", + new=mocked_get_accounts_v3, ), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": {"rates": {}}}, ), ): entry = await init_mock_coinbase(hass) @@ -61,13 +66,13 @@ async def test_option_updates( with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass) @@ -141,13 +146,13 @@ async def test_ignore_vaults_wallets( with ( patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), ), - patch("coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, ), ): config_entry = await init_mock_coinbase(hass, currencies=[GOOD_CURRENCY]) @@ -159,3 +164,54 @@ async def test_ignore_vaults_wallets( assert len(entities) == 1 entity = entities[0] assert API_TYPE_VAULT not in entity.original_name.lower() + + +async def test_v2_api_credentials_trigger_reauth(hass: HomeAssistant) -> None: + """Test that v2 API credentials trigger a reauth flow.""" + + config_entry_data = { + CONF_API_KEY: "v2_api_key_legacy_format", + CONF_API_TOKEN: "v2_api_secret", + } + + class MockConfigEntry: + def __init__(self, data) -> None: + self.data = data + self.options = {} + + entry = MockConfigEntry(config_entry_data) + + with pytest.raises(ConfigEntryAuthFailed) as exc_info: + create_and_update_instance(entry) + + assert "deprecated v2 API" in str(exc_info.value) + + +async def test_v3_api_credentials_work(hass: HomeAssistant) -> None: + """Test that v3 API credentials with 'organizations' don't trigger reauth.""" + + config_entry_data = { + CONF_API_KEY: "organizations_v3_api_key", + CONF_API_TOKEN: "v3_api_secret", + } + + class MockConfigEntry: + def __init__(self, data) -> None: + self.data = data + self.options = {} + + entry = MockConfigEntry(config_entry_data) + + with ( + patch( + "coinbase.rest.RESTClient.get_portfolios", + return_value=mock_get_portfolios(), + ), + patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3), + patch( + "coinbase.rest.RESTClient.get", + return_value={"data": mock_get_exchange_rates()}, + ), + ): + instance = create_and_update_instance(entry) + assert instance is not None