From 38c7eaf70a7493b493ad9e50c91a8faf323c2ebf Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:20:08 +0200 Subject: [PATCH] Add reauth flow to PlayStation Network integration (#147397) * Add reauth flow to psn integration * changes * catch auth error in coordinator --- .../playstation_network/config_flow.py | 62 ++++++- .../components/playstation_network/const.py | 3 + .../playstation_network/coordinator.py | 11 +- .../playstation_network/quality_scale.yaml | 2 +- .../playstation_network/strings.json | 14 +- .../playstation_network/test_config_flow.py | 160 +++++++++++++++++- 6 files changed, 243 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py index e2b402a212e..29ba8d4de90 100644 --- a/homeassistant/components/playstation_network/config_flow.py +++ b/homeassistant/components/playstation_network/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the PlayStation Network integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -14,8 +15,9 @@ from psnawp_api.utils.misc import parse_npsso_token import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_NAME -from .const import CONF_NPSSO, DOMAIN +from .const import CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK from .helpers import PlaystationNetwork _LOGGER = logging.getLogger(__name__) @@ -63,7 +65,61 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=STEP_USER_DATA_SCHEMA, errors=errors, description_placeholders={ - "npsso_link": "https://ca.account.sony.com/api/v1/ssocookie", - "psn_link": "https://playstation.com", + "npsso_link": NPSSO_LINK, + "psn_link": PSN_LINK, + }, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + + entry = self._get_reauth_entry() + + if user_input is not None: + try: + npsso = parse_npsso_token(user_input[CONF_NPSSO]) + psn = PlaystationNetwork(self.hass, npsso) + user: User = await psn.get_user() + except PSNAWPAuthenticationError: + errors["base"] = "invalid_auth" + except (PSNAWPNotFoundError, PSNAWPInvalidTokenError): + errors["base"] = "invalid_account" + except PSNAWPError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user.account_id) + self._abort_if_unique_id_mismatch( + description_placeholders={ + "wrong_account": user.online_id, + CONF_NAME: entry.title, + } + ) + + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_NPSSO: npsso}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + description_placeholders={ + "npsso_link": NPSSO_LINK, + "psn_link": PSN_LINK, }, ) diff --git a/homeassistant/components/playstation_network/const.py b/homeassistant/components/playstation_network/const.py index 2db43f433e6..77b43af3b73 100644 --- a/homeassistant/components/playstation_network/const.py +++ b/homeassistant/components/playstation_network/const.py @@ -13,3 +13,6 @@ SUPPORTED_PLATFORMS = { PlatformType.PS3, PlatformType.PSPC, } + +NPSSO_LINK: Final = "https://ca.account.sony.com/api/v1/ssocookie" +PSN_LINK: Final = "https://playstation.com" diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index f6fd53ccb24..2581a016feb 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -13,7 +13,7 @@ from psnawp_api.models.user import User from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -53,7 +53,7 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData try: self.user = await self.psn.get_user() except PSNAWPAuthenticationError as error: - raise ConfigEntryNotReady( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="not_ready", ) from error @@ -62,7 +62,12 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData """Get the latest data from the PSN.""" try: return await self.psn.get_data() - except (PSNAWPAuthenticationError, PSNAWPServerError) as error: + except PSNAWPAuthenticationError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="not_ready", + ) from error + except PSNAWPServerError as error: raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", diff --git a/homeassistant/components/playstation_network/quality_scale.yaml b/homeassistant/components/playstation_network/quality_scale.yaml index 36c28f19145..d5152927b99 100644 --- a/homeassistant/components/playstation_network/quality_scale.yaml +++ b/homeassistant/components/playstation_network/quality_scale.yaml @@ -39,7 +39,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index e4581322edb..19d61859f97 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -9,6 +9,16 @@ "npsso": "The NPSSO token is generated upon successful login of your PlayStation Network account and is used to authenticate your requests within Home Assistant." }, "description": "To obtain your NPSSO token, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token." + }, + "reauth_confirm": { + "title": "Re-authenticate {name} with PlayStation Network", + "description": "The NPSSO token for **{name}** has expired. To obtain a new one, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token.", + "data": { + "npsso": "[%key:component::playstation_network::config::step::user::data::npsso%]" + }, + "data_description": { + "npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]" + } } }, "error": { @@ -18,7 +28,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**" } }, "exceptions": { diff --git a/tests/components/playstation_network/test_config_flow.py b/tests/components/playstation_network/test_config_flow.py index 031872a7a0b..981e459d283 100644 --- a/tests/components/playstation_network/test_config_flow.py +++ b/tests/components/playstation_network/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.components.playstation_network.config_flow import ( PSNAWPNotFoundError, ) from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -138,3 +138,161 @@ async def test_parse_npsso_token_failures( assert result["data"] == { CONF_NPSSO: NPSSO_TOKEN, } + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_flow_reauth( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.data[CONF_NPSSO] == "NEW_NPSSO_TOKEN" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (PSNAWPNotFoundError(), "invalid_account"), + (PSNAWPAuthenticationError(), "invalid_auth"), + (PSNAWPError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_flow_reauth_errors( + hass: HomeAssistant, + mock_psnawpapi: MagicMock, + config_entry: MockConfigEntry, + raise_error: Exception, + text_error: str, +) -> None: + """Test reauth flow errors.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawpapi.user.side_effect = raise_error + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_psnawpapi.user.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.data[CONF_NPSSO] == "NEW_NPSSO_TOKEN" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_flow_reauth_token_error( + hass: HomeAssistant, + mock_psnawp_npsso: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow token error.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawp_npsso.side_effect = PSNAWPInvalidTokenError + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_account"} + + mock_psnawp_npsso.side_effect = lambda token: token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.data[CONF_NPSSO] == "NEW_NPSSO_TOKEN" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_flow_reauth_account_mismatch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_user: MagicMock, +) -> None: + """Test reauth flow unique_id mismatch.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + mock_user.account_id = "other_account" + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch"