Remove coinbase v2 API support (#148387)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Jamie Magee
2025-08-11 07:58:36 -07:00
committed by GitHub
parent a1dc3f3eac
commit 7688c367cc
11 changed files with 291 additions and 246 deletions

View File

@@ -7,12 +7,11 @@ import logging
from coinbase.rest import RESTClient from coinbase.rest import RESTClient
from coinbase.rest.rest_base import HTTPError 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.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.util import Throttle from homeassistant.util import Throttle
@@ -20,9 +19,7 @@ from .const import (
ACCOUNT_IS_VAULT, ACCOUNT_IS_VAULT,
API_ACCOUNT_AMOUNT, API_ACCOUNT_AMOUNT,
API_ACCOUNT_AVALIABLE, API_ACCOUNT_AVALIABLE,
API_ACCOUNT_BALANCE,
API_ACCOUNT_CURRENCY, API_ACCOUNT_CURRENCY,
API_ACCOUNT_CURRENCY_CODE,
API_ACCOUNT_HOLD, API_ACCOUNT_HOLD,
API_ACCOUNT_ID, API_ACCOUNT_ID,
API_ACCOUNT_NAME, API_ACCOUNT_NAME,
@@ -31,7 +28,6 @@ from .const import (
API_DATA, API_DATA,
API_RATES_CURRENCY, API_RATES_CURRENCY,
API_RESOURCE_TYPE, API_RESOURCE_TYPE,
API_TYPE_VAULT,
API_V3_ACCOUNT_ID, API_V3_ACCOUNT_ID,
API_V3_TYPE_VAULT, API_V3_TYPE_VAULT,
CONF_CURRENCIES, CONF_CURRENCIES,
@@ -68,16 +64,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) ->
def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData: def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData:
"""Create and update a Coinbase Data instance.""" """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]: if "organizations" not in entry.data[CONF_API_KEY]:
client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN]) # Trigger reauthentication to ask user for v3 credentials
version = "v2" raise ConfigEntryAuthFailed(
else: "Your Coinbase API key appears to be for the deprecated v2 API. "
client = RESTClient( "Please reconfigure with a new API key created for the v3 API. "
api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN] "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") base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD")
instance = CoinbaseData(client, base_rate, version) instance = CoinbaseData(client, base_rate)
instance.update() instance.update()
return instance return instance
@@ -105,31 +106,9 @@ async def update_listener(
registry.async_remove(entity.entity_id) registry.async_remove(entity.entity_id)
def get_accounts(client, version): def get_accounts(client):
"""Handle paginated accounts.""" """Handle paginated accounts."""
response = client.get_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] accounts = response[API_ACCOUNTS]
while response["has_next"]: while response["has_next"]:
response = client.get_accounts(cursor=response["cursor"]) response = client.get_accounts(cursor=response["cursor"])
@@ -153,37 +132,28 @@ def get_accounts(client, version):
class CoinbaseData: class CoinbaseData:
"""Get the latest data and update the states.""" """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.""" """Init the coinbase data object."""
self.client = client self.client = client
self.accounts = None self.accounts = None
self.exchange_base = exchange_base self.exchange_base = exchange_base
self.exchange_rates = None self.exchange_rates = None
if version == "v2": self.user_id = (
self.user_id = self.client.get_current_user()[API_ACCOUNT_ID] "v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID]
else: )
self.user_id = (
"v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID]
)
self.api_version = version
@Throttle(MIN_TIME_BETWEEN_UPDATES) @Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self): def update(self):
"""Get the latest data from coinbase.""" """Get the latest data from coinbase."""
try: try:
self.accounts = get_accounts(self.client, self.api_version) self.accounts = get_accounts(self.client)
if self.api_version == "v2": self.exchange_rates = self.client.get(
self.exchange_rates = self.client.get_exchange_rates( "/v2/exchange-rates",
currency=self.exchange_base params={API_RATES_CURRENCY: self.exchange_base},
) )[API_DATA]
else: except HTTPError as coinbase_error:
self.exchange_rates = self.client.get(
"/v2/exchange-rates",
params={API_RATES_CURRENCY: self.exchange_base},
)[API_DATA]
except (AuthenticationError, HTTPError) as coinbase_error:
_LOGGER.error( _LOGGER.error(
"Authentication error connecting to coinbase: %s", coinbase_error "Authentication error connecting to coinbase: %s", coinbase_error
) )

View File

@@ -2,17 +2,16 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from coinbase.rest import RESTClient from coinbase.rest import RESTClient
from coinbase.rest.rest_base import HTTPError 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 import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow 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.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv 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): def get_user_from_client(api_key, api_token):
"""Get the user name from Coinbase API credentials.""" """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) client = RESTClient(api_key=api_key, api_secret=api_token)
return client.get_portfolios()["portfolios"][0]["name"] 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( user = await hass.async_add_executor_job(
get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN] 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): 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") _LOGGER.debug("Coinbase rejected API credentials due to an invalid API key")
raise InvalidKey from error raise InvalidKey from error
@@ -74,8 +70,8 @@ async def validate_api(hass: HomeAssistant, data):
raise InvalidAuth from error raise InvalidAuth from error
except ConnectionError as error: except ConnectionError as error:
raise CannotConnect from 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( async def validate_options(
@@ -85,20 +81,17 @@ async def validate_options(
client = config_entry.runtime_data.client client = config_entry.runtime_data.client
accounts = await hass.async_add_executor_job( accounts = await hass.async_add_executor_job(get_accounts, client)
get_accounts, client, config_entry.data.get("api_version", "v2")
)
accounts_currencies = [ accounts_currencies = [
account[API_ACCOUNT_CURRENCY] account[API_ACCOUNT_CURRENCY]
for account in accounts for account in accounts
if not account[ACCOUNT_IS_VAULT] 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) resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates")
else: 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: if CONF_CURRENCIES in options:
for currency in options[CONF_CURRENCIES]: for currency in options[CONF_CURRENCIES]:
if currency not in accounts_currencies: if currency not in accounts_currencies:
@@ -117,6 +110,8 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
reauth_entry: CoinbaseConfigEntry
async def async_step_user( async def async_step_user(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -143,12 +138,63 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
user_input[CONF_API_VERSION] = info["api_version"]
return self.async_create_entry(title=info["title"], data=user_input) return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors 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 @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/coinbase", "documentation": "https://www.home-assistant.io/integrations/coinbase",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["coinbase"], "loggers": ["coinbase"],
"requirements": ["coinbase==2.1.0", "coinbase-advanced-py==1.2.2"] "requirements": ["coinbase-advanced-py==1.2.2"]
} }

View File

@@ -27,7 +27,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ATTR_NATIVE_BALANCE = "Balance in native currency" ATTR_NATIVE_BALANCE = "Balance in native currency"
ATTR_API_VERSION = "API Version"
CURRENCY_ICONS = { CURRENCY_ICONS = {
"BTC": "mdi:currency-btc", "BTC": "mdi:currency-btc",
@@ -71,9 +70,8 @@ async def async_setup_entry(
for currency in desired_currencies: for currency in desired_currencies:
_LOGGER.debug( _LOGGER.debug(
"Attempting to set up %s account sensor with %s API", "Attempting to set up %s account sensor",
currency, currency,
instance.api_version,
) )
if currency not in provided_currencies: if currency not in provided_currencies:
_LOGGER.warning( _LOGGER.warning(
@@ -89,9 +87,8 @@ async def async_setup_entry(
if CONF_EXCHANGE_RATES in config_entry.options: if CONF_EXCHANGE_RATES in config_entry.options:
for rate in config_entry.options[CONF_EXCHANGE_RATES]: for rate in config_entry.options[CONF_EXCHANGE_RATES]:
_LOGGER.debug( _LOGGER.debug(
"Attempting to set up %s account sensor with %s API", "Attempting to set up %s exchange rate sensor",
rate, rate,
instance.api_version,
) )
entities.append( entities.append(
ExchangeRateSensor( ExchangeRateSensor(
@@ -146,15 +143,13 @@ class AccountSensor(SensorEntity):
"""Return the state attributes of the sensor.""" """Return the state attributes of the sensor."""
return { return {
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}", ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}",
ATTR_API_VERSION: self._coinbase_data.api_version,
} }
def update(self) -> None: def update(self) -> None:
"""Get the latest state of the sensor.""" """Get the latest state of the sensor."""
_LOGGER.debug( _LOGGER.debug(
"Updating %s account sensor with %s API", "Updating %s account sensor",
self._currency, self._currency,
self._coinbase_data.api_version,
) )
self._coinbase_data.update() self._coinbase_data.update()
for account in self._coinbase_data.accounts: for account in self._coinbase_data.accounts:
@@ -210,9 +205,8 @@ class ExchangeRateSensor(SensorEntity):
def update(self) -> None: def update(self) -> None:
"""Get the latest state of the sensor.""" """Get the latest state of the sensor."""
_LOGGER.debug( _LOGGER.debug(
"Updating %s rate sensor with %s API", "Updating %s rate sensor",
self._currency, self._currency,
self._coinbase_data.api_version,
) )
self._coinbase_data.update() self._coinbase_data.update()
self._attr_native_value = round( self._attr_native_value = round(

View File

@@ -8,6 +8,14 @@
"api_key": "[%key:common::config_flow::data::api_key%]", "api_key": "[%key:common::config_flow::data::api_key%]",
"api_token": "API secret" "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": { "error": {
@@ -18,7 +26,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "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": { "options": {

3
requirements_all.txt generated
View File

@@ -724,9 +724,6 @@ clx-sdk-xms==1.0.0
# homeassistant.components.coinbase # homeassistant.components.coinbase
coinbase-advanced-py==1.2.2 coinbase-advanced-py==1.2.2
# homeassistant.components.coinbase
coinbase==2.1.0
# homeassistant.scripts.check_config # homeassistant.scripts.check_config
colorlog==6.9.0 colorlog==6.9.0

View File

@@ -633,9 +633,6 @@ caldav==1.6.0
# homeassistant.components.coinbase # homeassistant.components.coinbase
coinbase-advanced-py==1.2.2 coinbase-advanced-py==1.2.2
# homeassistant.components.coinbase
coinbase==2.1.0
# homeassistant.scripts.check_config # homeassistant.scripts.check_config
colorlog==6.9.0 colorlog==6.9.0

View File

@@ -5,7 +5,7 @@ from homeassistant.components.coinbase.const import (
CONF_EXCHANGE_RATES, CONF_EXCHANGE_RATES,
DOMAIN, 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.core import HomeAssistant
from .const import ( from .const import (
@@ -65,7 +65,7 @@ class MockGetAccountsV3:
start = ids.index(cursor) if cursor else 0 start = ids.index(cursor) if cursor else 0
has_next = (target_end := start + 2) < len(MOCK_ACCOUNTS_RESPONSE_V3) 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] next_cursor = ids[end] if has_next else ids[-1]
self.accounts = { self.accounts = {
"accounts": MOCK_ACCOUNTS_RESPONSE_V3[start:end], "accounts": MOCK_ACCOUNTS_RESPONSE_V3[start:end],
@@ -120,31 +120,6 @@ async def init_mock_coinbase(
hass: HomeAssistant, hass: HomeAssistant,
currencies: list[str] | None = None, currencies: list[str] | None = None,
rates: 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: ) -> MockConfigEntry:
"""Init Coinbase integration for testing.""" """Init Coinbase integration for testing."""
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
@@ -155,7 +130,6 @@ async def init_mock_coinbase_v3(
data={ data={
CONF_API_KEY: "organizations/123456", CONF_API_KEY: "organizations/123456",
CONF_API_TOKEN: "AbCDeF", CONF_API_TOKEN: "AbCDeF",
CONF_API_VERSION: "v3",
}, },
options={ options={
CONF_CURRENCIES: currencies or [], CONF_CURRENCIES: currencies or [],

View File

@@ -3,9 +3,8 @@
import logging import logging
from unittest.mock import patch from unittest.mock import patch
from coinbase.wallet.error import AuthenticationError from coinbase.rest.rest_base import HTTPError
import pytest import pytest
from requests.models import Response
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.coinbase.const import ( from homeassistant.components.coinbase.const import (
@@ -14,17 +13,14 @@ from homeassistant.components.coinbase.const import (
CONF_EXCHANGE_RATES, CONF_EXCHANGE_RATES,
DOMAIN, 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.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from .common import ( from .common import (
init_mock_coinbase, init_mock_coinbase,
init_mock_coinbase_v3,
mock_get_current_user,
mock_get_exchange_rates, mock_get_exchange_rates,
mock_get_portfolios, mock_get_portfolios,
mocked_get_accounts,
mocked_get_accounts_v3, mocked_get_accounts_v3,
) )
from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHANGE_RATE 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 ( with (
patch( patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_current_user(), 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( patch(
"coinbase.wallet.client.Client.get_exchange_rates", "coinbase.rest.RESTClient.get",
return_value=mock_get_exchange_rates(), return_value={"data": mock_get_exchange_rates()},
), ),
patch( patch(
"homeassistant.components.coinbase.async_setup_entry", "homeassistant.components.coinbase.async_setup_entry",
@@ -61,11 +57,10 @@ async def test_form(hass: HomeAssistant) -> None:
await hass.async_block_till_done() await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Test User" assert result2["title"] == "Default"
assert result2["data"] == { assert result2["data"] == {
CONF_API_KEY: "123456", CONF_API_KEY: "123456",
CONF_API_TOKEN: "AbCDeF", CONF_API_TOKEN: "AbCDeF",
CONF_API_VERSION: "v2",
} }
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@@ -80,16 +75,9 @@ async def test_form_invalid_auth(
caplog.set_level(logging.DEBUG) caplog.set_level(logging.DEBUG)
response = Response() api_auth_error_unknown = HTTPError("unknown error")
response.status_code = 401
api_auth_error_unknown = AuthenticationError(
response,
"authentication_error",
"unknown error",
[{"id": "authentication_error", "message": "unknown error"}],
)
with patch( with patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
side_effect=api_auth_error_unknown, side_effect=api_auth_error_unknown,
): ):
result2 = await hass.config_entries.flow.async_configure( 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 result2["errors"] == {"base": "invalid_auth"}
assert "Coinbase rejected API credentials due to an unknown error" in caplog.text assert "Coinbase rejected API credentials due to an unknown error" in caplog.text
api_auth_error_key = AuthenticationError( api_auth_error_key = HTTPError("invalid api key")
response,
"authentication_error",
"invalid api key",
[{"id": "authentication_error", "message": "invalid api key"}],
)
with patch( with patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
side_effect=api_auth_error_key, side_effect=api_auth_error_key,
): ):
result2 = await hass.config_entries.flow.async_configure( 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 result2["errors"] == {"base": "invalid_auth_key"}
assert "Coinbase rejected API credentials due to an invalid API key" in caplog.text assert "Coinbase rejected API credentials due to an invalid API key" in caplog.text
api_auth_error_secret = AuthenticationError( api_auth_error_secret = HTTPError("invalid signature")
response,
"authentication_error",
"invalid signature",
[{"id": "authentication_error", "message": "invalid signature"}],
)
with patch( with patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
side_effect=api_auth_error_secret, side_effect=api_auth_error_secret,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
@@ -158,7 +136,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None:
) )
with patch( with patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
side_effect=ConnectionError, side_effect=ConnectionError,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
@@ -180,7 +158,7 @@ async def test_form_catch_all_exception(hass: HomeAssistant) -> None:
) )
with patch( with patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
side_effect=Exception, side_effect=Exception,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
@@ -200,13 +178,13 @@ async def test_option_form(hass: HomeAssistant) -> None:
with ( with (
patch( patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_current_user(), 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( patch(
"coinbase.wallet.client.Client.get_exchange_rates", "coinbase.rest.RESTClient.get",
return_value=mock_get_exchange_rates(), return_value={"data": mock_get_exchange_rates()},
), ),
patch( patch(
"homeassistant.components.coinbase.update_listener" "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.""" """Test we handle a bad currency option."""
with ( with (
patch( patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_current_user(), 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( patch(
"coinbase.wallet.client.Client.get_exchange_rates", "coinbase.rest.RESTClient.get",
return_value=mock_get_exchange_rates(), return_value={"data": mock_get_exchange_rates()},
), ),
): ):
config_entry = await init_mock_coinbase(hass) 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.""" """Test we handle a bad exchange rate."""
with ( with (
patch( patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_current_user(), 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( patch(
"coinbase.wallet.client.Client.get_exchange_rates", "coinbase.rest.RESTClient.get",
return_value=mock_get_exchange_rates(), return_value={"data": mock_get_exchange_rates()},
), ),
): ):
config_entry = await init_mock_coinbase(hass) 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.""" """Test we handle an unknown exception in the option flow."""
with ( with (
patch( patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_current_user(), 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( patch(
"coinbase.wallet.client.Client.get_exchange_rates", "coinbase.rest.RESTClient.get",
return_value=mock_get_exchange_rates(), return_value={"data": mock_get_exchange_rates()},
), ),
): ):
config_entry = await init_mock_coinbase(hass) 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() await hass.async_block_till_done()
with patch( with patch(
"coinbase.wallet.client.Client.get_accounts", "coinbase.rest.RESTClient.get_accounts",
side_effect=Exception, side_effect=Exception,
): ):
result2 = await hass.config_entries.options.async_configure( 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"} assert result2["errors"] == {"base": "unknown"}
async def test_form_v3(hass: HomeAssistant) -> None: async def test_reauth_flow(hass: HomeAssistant) -> None:
"""Test we get the form.""" """Test reauth flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
with ( with (
patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3),
patch( patch(
"coinbase.rest.RESTClient.get_portfolios", "coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_portfolios(), return_value=mock_get_portfolios(),
), ),
patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3),
patch( patch(
"coinbase.rest.RESTBase.get", "coinbase.rest.RESTClient.get",
return_value={"data": mock_get_exchange_rates()}, 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( patch(
"homeassistant.components.coinbase.async_setup_entry", "coinbase.rest.RESTClient.get_portfolios",
return_value=True, return_value=mock_get_portfolios(),
) as mock_setup_entry, ),
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( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], 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["type"] is FlowResultType.ABORT
assert result2["title"] == "Default" assert result2["reason"] == "reauth_successful"
assert result2["data"] == { assert config_entry.data[CONF_API_KEY] == "new_key"
CONF_API_KEY: "organizations/123456", assert config_entry.data[CONF_API_TOKEN] == "new_secret"
CONF_API_TOKEN: "AbCDeF",
CONF_API_VERSION: "v3",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_option_form_v3(hass: HomeAssistant) -> None: async def test_reauth_flow_invalid_auth(hass: HomeAssistant) -> None:
"""Test we handle a good wallet currency option.""" """Test reauth flow with invalid credentials."""
with ( with (
patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3),
patch( patch(
"coinbase.rest.RESTClient.get_portfolios", "coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_portfolios(), return_value=mock_get_portfolios(),
), ),
patch("coinbase.rest.RESTClient.get_accounts", new=mocked_get_accounts_v3),
patch( patch(
"coinbase.rest.RESTBase.get", "coinbase.rest.RESTClient.get",
return_value={"data": mock_get_exchange_rates()}, 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) config_entry = await init_mock_coinbase(hass)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id) # Start reauth flow
await hass.async_block_till_done() result = await hass.config_entries.flow.async_init(
result2 = await hass.config_entries.options.async_configure( 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"], result["flow_id"],
user_input={ {
CONF_CURRENCIES: [GOOD_CURRENCY], CONF_API_KEY: "bad_key",
CONF_EXCHANGE_RATES: [GOOD_EXCHANGE_RATE], CONF_API_TOKEN: "bad_secret",
CONF_EXCHANGE_PRECISION: 5,
}, },
) )
assert result2["type"] is FlowResultType.CREATE_ENTRY
await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM
assert len(mock_update_listener.mock_calls) == 1 assert result2["step_id"] == "reauth_confirm"
assert result2["errors"] == {"base": "invalid_auth_key"}

View File

@@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant
from .common import ( from .common import (
init_mock_coinbase, init_mock_coinbase,
mock_get_current_user,
mock_get_exchange_rates, mock_get_exchange_rates,
mocked_get_accounts, mock_get_portfolios,
mocked_get_accounts_v3,
) )
from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.components.diagnostics import get_diagnostics_for_config_entry
@@ -27,13 +27,13 @@ async def test_entry_diagnostics(
with ( with (
patch( patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_current_user(), 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( patch(
"coinbase.wallet.client.Client.get_exchange_rates", "coinbase.rest.RESTClient.get",
return_value=mock_get_exchange_rates(), return_value={"data": mock_get_exchange_rates()},
), ),
): ):
config_entry = await init_mock_coinbase(hass) config_entry = await init_mock_coinbase(hass)

View File

@@ -2,6 +2,9 @@
from unittest.mock import patch from unittest.mock import patch
import pytest
from homeassistant.components.coinbase import create_and_update_instance
from homeassistant.components.coinbase.const import ( from homeassistant.components.coinbase.const import (
API_TYPE_VAULT, API_TYPE_VAULT,
CONF_CURRENCIES, CONF_CURRENCIES,
@@ -9,14 +12,16 @@ from homeassistant.components.coinbase.const import (
DOMAIN, DOMAIN,
) )
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from .common import ( from .common import (
init_mock_coinbase, init_mock_coinbase,
mock_get_current_user,
mock_get_exchange_rates, mock_get_exchange_rates,
mocked_get_accounts, mock_get_portfolios,
mocked_get_accounts_v3,
) )
from .const import ( from .const import (
GOOD_CURRENCY, GOOD_CURRENCY,
@@ -30,16 +35,16 @@ async def test_unload_entry(hass: HomeAssistant) -> None:
"""Test successful unload of entry.""" """Test successful unload of entry."""
with ( with (
patch( patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_current_user(), return_value=mock_get_portfolios(),
), ),
patch( patch(
"coinbase.wallet.client.Client.get_accounts", "coinbase.rest.RESTClient.get_accounts",
new=mocked_get_accounts, new=mocked_get_accounts_v3,
), ),
patch( patch(
"coinbase.wallet.client.Client.get_exchange_rates", "coinbase.rest.RESTClient.get",
return_value=mock_get_exchange_rates(), return_value={"data": {"rates": {}}},
), ),
): ):
entry = await init_mock_coinbase(hass) entry = await init_mock_coinbase(hass)
@@ -61,13 +66,13 @@ async def test_option_updates(
with ( with (
patch( patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_current_user(), 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( patch(
"coinbase.wallet.client.Client.get_exchange_rates", "coinbase.rest.RESTClient.get",
return_value=mock_get_exchange_rates(), return_value={"data": mock_get_exchange_rates()},
), ),
): ):
config_entry = await init_mock_coinbase(hass) config_entry = await init_mock_coinbase(hass)
@@ -141,13 +146,13 @@ async def test_ignore_vaults_wallets(
with ( with (
patch( patch(
"coinbase.wallet.client.Client.get_current_user", "coinbase.rest.RESTClient.get_portfolios",
return_value=mock_get_current_user(), 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( patch(
"coinbase.wallet.client.Client.get_exchange_rates", "coinbase.rest.RESTClient.get",
return_value=mock_get_exchange_rates(), return_value={"data": mock_get_exchange_rates()},
), ),
): ):
config_entry = await init_mock_coinbase(hass, currencies=[GOOD_CURRENCY]) config_entry = await init_mock_coinbase(hass, currencies=[GOOD_CURRENCY])
@@ -159,3 +164,54 @@ async def test_ignore_vaults_wallets(
assert len(entities) == 1 assert len(entities) == 1
entity = entities[0] entity = entities[0]
assert API_TYPE_VAULT not in entity.original_name.lower() 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