diff --git a/homeassistant/components/nest/quality_scale.yaml b/homeassistant/components/nest/quality_scale.yaml index 1f1714362fc..808e03f2b63 100644 --- a/homeassistant/components/nest/quality_scale.yaml +++ b/homeassistant/components/nest/quality_scale.yaml @@ -30,13 +30,7 @@ rules: observed are related to ipv6 misconfigurations and the error messages are not self explanatory and can be improved. docs-high-level-description: done - config-flow-test-coverage: - status: todo - comment: | - The integration has full test coverage however it does not yet assert the specific contents of the - unique id of the created entry. Additional tests coverage for combinations of features like - `test_dhcp_discovery_with_creds` would also be useful. - Tests can be improved so that all end in either CREATE_ENTRY or ABORT. + config-flow-test-coverage: done docs-actions: done runtime-data: done diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index 67364aff412..24b12b047bf 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from http import HTTPStatus from typing import Any from unittest.mock import patch @@ -174,7 +175,7 @@ class OAuthFixture: selected_subscription: str = "create_new_subscription", user_input: dict | None = None, existing_errors: dict | None = None, - ) -> ConfigEntry: + ) -> FlowResult: """Fixture to walk through the Pub/Sub topic and subscription steps. This picks a simple set of steps that are reusable for most flows without @@ -213,19 +214,19 @@ class OAuthFixture: async def async_finish_setup( self, result: dict, user_input: dict | None = None - ) -> ConfigEntry: + ) -> FlowResult: """Finish the OAuth flow exchanging auth token for refresh token.""" with patch( "homeassistant.components.nest.async_setup_entry", return_value=True ) as mock_setup: - await self.async_configure(result, user_input) + new_result = await self.async_configure(result, user_input) assert len(mock_setup.mock_calls) == 1 await self.hass.async_block_till_done() - return self.get_config_entry() + return new_result async def async_configure( self, result: dict[str, Any], user_input: dict[str, Any] - ) -> dict: + ) -> FlowResult: """Advance to the next step in the config flow.""" return await self.hass.config_entries.flow.async_configure( result["flow_id"], @@ -252,7 +253,7 @@ async def oauth( @pytest.fixture(name="sdm_managed_topic") def mock_sdm_managed_topic() -> bool: - """Fixture to configure fake server responses for SDM owend Pub/Sub topics.""" + """Fixture to configure fake server responses for SDM owned Pub/Sub topics.""" return False @@ -274,30 +275,6 @@ def mock_cloud_project_id() -> str: return CLOUD_PROJECT_ID -@pytest.fixture(name="create_topic_status") -def mock_create_topic_status() -> str: - """Fixture to configure the return code when creating the topic.""" - return HTTPStatus.OK - - -@pytest.fixture(name="create_subscription_status") -def mock_create_subscription_status() -> str: - """Fixture to configure the return code when creating the subscription.""" - return HTTPStatus.OK - - -@pytest.fixture(name="list_topics_status") -def mock_list_topics_status() -> str: - """Fixture to configure the return code when listing topics.""" - return HTTPStatus.OK - - -@pytest.fixture(name="list_subscriptions_status") -def mock_list_subscriptions_status() -> str: - """Fixture to configure the return code when listing subscriptions.""" - return HTTPStatus.OK - - def setup_mock_list_subscriptions_responses( aioclient_mock: AiohttpClientMocker, cloud_project_id: str, @@ -356,59 +333,180 @@ def setup_mock_create_subscription_responses( ) -@pytest.fixture(autouse=True) -def mock_pubsub_api_responses( +class MockPubSubAPIResponses: + """Configure mock Pub/Sub API responses for config flow tests.""" + + def __init__(self, aioclient_mock: AiohttpClientMocker) -> None: + """Initialize the mock Pub/Sub API responses.""" + self.aioclient_mock = aioclient_mock + self.sdm_managed_topic = False + self.user_managed_topics: list[str] = [] + self.subscriptions: list[tuple[str, str]] = [] + self.device_access_project_id = PROJECT_ID + self.cloud_project_id = CLOUD_PROJECT_ID + self.create_topic_status = HTTPStatus.OK + self.create_subscription_status = HTTPStatus.OK + self.list_topics_status = HTTPStatus.OK + self.list_subscriptions_status = HTTPStatus.OK + + def register_mock_requests(self) -> None: + """Configure a server response for an SDM managed Pub/Sub topic. + + We check for a topic created by the SDM Device Access Console (but note we don't have permission to read it) + or the user has created one themselves in the Google Cloud Project. + """ + self.aioclient_mock.get( + f"https://pubsub.googleapis.com/v1/projects/sdm-prod/topics/enterprise-{self.device_access_project_id}", + status=HTTPStatus.FORBIDDEN + if self.sdm_managed_topic + else HTTPStatus.NOT_FOUND, + ) + self.aioclient_mock.get( + f"https://pubsub.googleapis.com/v1/projects/{self.cloud_project_id}/topics", + json={ + "topics": [ + { + "name": topic_name, + } + for topic_name in self.user_managed_topics or () + ] + }, + status=self.list_topics_status, + ) + # We check for a topic created by the SDM Device Access Console (but note we don't have permission to read it) + # or the user has created one themselves in the Google Cloud Project. + setup_mock_list_subscriptions_responses( + self.aioclient_mock, + self.cloud_project_id, + self.subscriptions, + self.list_subscriptions_status, + ) + setup_mock_create_topic_responses( + self.aioclient_mock, self.cloud_project_id, self.create_topic_status + ) + setup_mock_create_subscription_responses( + self.aioclient_mock, self.cloud_project_id, self.create_subscription_status + ) + + +@pytest.fixture(autouse=True, name="mock_pubsub_api_responses") +def mock_pubsub_api_responses_fixture( aioclient_mock: AiohttpClientMocker, sdm_managed_topic: bool, user_managed_topics: list[str], subscriptions: list[tuple[str, str]], device_access_project_id: str, cloud_project_id: str, - create_topic_status: HTTPStatus, - create_subscription_status: HTTPStatus, - list_topics_status: HTTPStatus, - list_subscriptions_status: HTTPStatus, -) -> None: - """Configure a server response for an SDM managed Pub/Sub topic. - - We check for a topic created by the SDM Device Access Console (but note we don't have permission to read it) - or the user has created one themselves in the Google Cloud Project. - """ - aioclient_mock.get( - f"https://pubsub.googleapis.com/v1/projects/sdm-prod/topics/enterprise-{device_access_project_id}", - status=HTTPStatus.FORBIDDEN if sdm_managed_topic else HTTPStatus.NOT_FOUND, - ) - aioclient_mock.get( - f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/topics", - json={ - "topics": [ - { - "name": topic_name, - } - for topic_name in user_managed_topics or () - ] - }, - status=list_topics_status, - ) - # We check for a topic created by the SDM Device Access Console (but note we don't have permission to read it) - # or the user has created one themselves in the Google Cloud Project. - setup_mock_list_subscriptions_responses( - aioclient_mock, cloud_project_id, subscriptions, list_subscriptions_status - ) - setup_mock_create_topic_responses( - aioclient_mock, cloud_project_id, create_topic_status - ) - setup_mock_create_subscription_responses( - aioclient_mock, cloud_project_id, create_subscription_status - ) +) -> MockPubSubAPIResponses: + """Fixture to configure mock Pub/Sub API responses for config flow tests.""" + mock_responses = MockPubSubAPIResponses(aioclient_mock) + mock_responses.sdm_managed_topic = sdm_managed_topic + mock_responses.user_managed_topics = user_managed_topics + mock_responses.subscriptions = subscriptions + mock_responses.device_access_project_id = device_access_project_id + mock_responses.cloud_project_id = cloud_project_id + mock_responses.register_mock_requests() + return mock_responses -@pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) -async def test_app_credentials( +@pytest.mark.parametrize( + ( + "sdm_managed_topic", + "user_managed_topics", + "subscriptions", + "topic_args", + "expected_config_entry_data", + ), + [ + ( + True, + [], + [], + {"selected_topic": "create_new_topic"}, + { + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/home-assistant-{RAND_SUFFIX}", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + }, + ), + ( + False, + [ + f"projects/{CLOUD_PROJECT_ID}/topics/my-topic", + ], + [ + ( + f"projects/{CLOUD_PROJECT_ID}/subscriptions/my-subscription", + f"projects/{CLOUD_PROJECT_ID}/topics/my-topic", + ), + ], + { + "selected_topic": "create_new_topic", + "selected_subscription": "create_new_subscription", + }, + { + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/home-assistant-{RAND_SUFFIX}", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + }, + ), + ( + False, + [ + f"projects/{CLOUD_PROJECT_ID}/topics/my-topic", + ], + [ + ( + f"projects/{CLOUD_PROJECT_ID}/subscriptions/my-subscription", + f"projects/{CLOUD_PROJECT_ID}/topics/my-topic", + ), + ], + { + "selected_topic": f"projects/{CLOUD_PROJECT_ID}/topics/my-topic", + "selected_subscription": "create_new_subscription", + }, + { + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/my-topic", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + }, + ), + ( + False, + [ + f"projects/{CLOUD_PROJECT_ID}/topics/my-topic", + ], + [ + ( + f"projects/{CLOUD_PROJECT_ID}/subscriptions/my-subscription", + f"projects/{CLOUD_PROJECT_ID}/topics/my-topic", + ), + ], + { + "selected_topic": f"projects/{CLOUD_PROJECT_ID}/topics/my-topic", + "selected_subscription": f"projects/{CLOUD_PROJECT_ID}/subscriptions/my-subscription", + }, + { + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/my-topic", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/my-subscription", + "subscriber_id_imported": True, + }, + ), + ], + ids=[ + "sdm-managed-topic", + "existing-topics-ignored", + "user-managed-topic", + "user-managed-topic-existing-subscription", + ], +) +async def test_full_flow( hass: HomeAssistant, oauth: OAuthFixture, + mock_pubsub_api_responses: MockPubSubAPIResponses, + topic_args: dict[str, str], + expected_config_entry_data: dict[str, Any], ) -> None: """Check full flow.""" + mock_pubsub_api_responses.sdm_managed_topic = True + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -416,10 +514,14 @@ async def test_app_credentials( oauth.async_mock_refresh() result = await oauth.async_configure(result, None) - entry = await oauth.async_complete_pubsub_flow( - result, selected_topic=f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}" + result = await oauth.async_complete_pubsub_flow( + result, + **topic_args, ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == PROJECT_ID + entry = oauth.get_config_entry() data = dict(entry.data) assert "token" in data data["token"].pop("expires_in") @@ -429,21 +531,122 @@ async def test_app_credentials( "auth_implementation": "imported-cred", "cloud_project_id": CLOUD_PROJECT_ID, "project_id": PROJECT_ID, - "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", - "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", "token": { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", "type": "Bearer", }, + **expected_config_entry_data, } +@pytest.mark.parametrize(("device_access_project_id"), [("new-project-id")]) +@pytest.mark.parametrize(("cloud_project_id"), [("new-cloud-project-id")]) @pytest.mark.parametrize( - ("sdm_managed_topic", "device_access_project_id", "cloud_project_id"), - [(True, "new-project-id", "new-cloud-project-id")], + ( + "sdm_managed_topic", + "user_managed_topics", + "subscriptions", + "topic_args", + "expected_config_entry_data", + ), + [ + ( + True, + [], + [], + {"selected_topic": "projects/sdm-prod/topics/enterprise-new-project-id"}, + { + "topic_name": "projects/sdm-prod/topics/enterprise-new-project-id", + "subscription_name": f"projects/new-cloud-project-id/subscriptions/home-assistant-{RAND_SUFFIX}", + }, + ), + ( + True, + [], + [], + {"selected_topic": "create_new_topic"}, + { + "topic_name": f"projects/new-cloud-project-id/topics/home-assistant-{RAND_SUFFIX}", + "subscription_name": f"projects/new-cloud-project-id/subscriptions/home-assistant-{RAND_SUFFIX}", + }, + ), + ( + False, + [ + "projects/new-project-id/topics/my-topic", + ], + [ + ( + "projects/new-project-id/subscriptions/my-subscription", + "projects/new-project-id/topics/my-topic", + ), + ], + { + "selected_topic": "create_new_topic", + "selected_subscription": "create_new_subscription", + }, + { + "topic_name": f"projects/new-cloud-project-id/topics/home-assistant-{RAND_SUFFIX}", + "subscription_name": f"projects/new-cloud-project-id/subscriptions/home-assistant-{RAND_SUFFIX}", + }, + ), + ( + False, + [ + "projects/new-cloud-project-id/topics/my-topic", + ], + [ + ( + "projects/new-cloud-project-id/subscriptions/my-subscription", + "projects/new-cloud-project-id/topics/my-topic", + ), + ], + { + "selected_topic": "projects/new-cloud-project-id/topics/my-topic", + "selected_subscription": "create_new_subscription", + }, + { + "topic_name": "projects/new-cloud-project-id/topics/my-topic", + "subscription_name": f"projects/new-cloud-project-id/subscriptions/home-assistant-{RAND_SUFFIX}", + }, + ), + ( + False, + [ + "projects/new-cloud-project-id/topics/my-topic", + ], + [ + ( + "projects/new-cloud-project-id/subscriptions/my-subscription", + "projects/new-cloud-project-id/topics/my-topic", + ), + ], + { + "selected_topic": "projects/new-cloud-project-id/topics/my-topic", + "selected_subscription": "projects/new-cloud-project-id/subscriptions/my-subscription", + }, + { + "topic_name": "projects/new-cloud-project-id/topics/my-topic", + "subscription_name": "projects/new-cloud-project-id/subscriptions/my-subscription", + "subscriber_id_imported": True, + }, + ), + ], + ids=[ + "sdm-managed-topic-only", + "sdm-managed-topic", + "existing-topics-ignored", + "user-managed-topic", + "user-managed-topic-existing-subscription", + ], ) -async def test_config_flow_restart(hass: HomeAssistant, oauth: OAuthFixture) -> None: +async def test_config_flow_restart( + hass: HomeAssistant, + oauth: OAuthFixture, + topic_args: dict[str, str], + expected_config_entry_data: dict[str, Any], +) -> None: """Check with auth implementation is re-initialized when aborting the flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -472,10 +675,14 @@ async def test_config_flow_restart(hass: HomeAssistant, oauth: OAuthFixture) -> oauth.async_mock_refresh() result = await oauth.async_configure(result, {"code": "1234"}) - entry = await oauth.async_complete_pubsub_flow( - result, selected_topic="projects/sdm-prod/topics/enterprise-new-project-id" + result = await oauth.async_complete_pubsub_flow( + result, + **topic_args, ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == "new-project-id" + entry = oauth.get_config_entry() data = dict(entry.data) assert "token" in data data["token"].pop("expires_in") @@ -485,8 +692,7 @@ async def test_config_flow_restart(hass: HomeAssistant, oauth: OAuthFixture) -> "auth_implementation": "imported-cred", "cloud_project_id": "new-cloud-project-id", "project_id": "new-project-id", - "subscription_name": "projects/new-cloud-project-id/subscriptions/home-assistant-ABCDEF", - "topic_name": "projects/sdm-prod/topics/enterprise-new-project-id", + **expected_config_entry_data, "token": { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", @@ -495,7 +701,7 @@ async def test_config_flow_restart(hass: HomeAssistant, oauth: OAuthFixture) -> } -@pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) +@pytest.mark.parametrize(("sdm_managed_topic"), [True]) async def test_config_flow_wrong_project_id( hass: HomeAssistant, oauth: OAuthFixture, @@ -526,10 +732,13 @@ async def test_config_flow_wrong_project_id( oauth.async_mock_refresh() result = await oauth.async_configure(result, {"code": "1234"}) - entry = await oauth.async_complete_pubsub_flow( + result = await oauth.async_complete_pubsub_flow( result, selected_topic="projects/sdm-prod/topics/enterprise-some-project-id" ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == PROJECT_ID + entry = oauth.get_config_entry() data = dict(entry.data) assert "token" in data data["token"].pop("expires_in") @@ -549,14 +758,25 @@ async def test_config_flow_wrong_project_id( } +@pytest.mark.parametrize(("sdm_managed_topic"), [True]) @pytest.mark.parametrize( - ("sdm_managed_topic", "create_subscription_status"), [(True, HTTPStatus.NOT_FOUND)] + ("create_subscription_status"), + [HTTPStatus.NOT_FOUND, HTTPStatus.INTERNAL_SERVER_ERROR, HTTPStatus.UNAUTHORIZED], ) -async def test_config_flow_pubsub_configuration_error( +async def test_config_flow_pubsub_create_subscription_failure( hass: HomeAssistant, oauth: OAuthFixture, + aioclient_mock: AiohttpClientMocker, + auth: FakeAuth, + mock_pubsub_api_responses: MockPubSubAPIResponses, + create_subscription_status: HTTPStatus, ) -> None: """Check full flow fails with configuration error.""" + aioclient_mock.clear_requests() + auth.register_mock_requests() + mock_pubsub_api_responses.create_subscription_status = create_subscription_status + mock_pubsub_api_responses.register_mock_requests() + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -598,64 +818,65 @@ async def test_config_flow_pubsub_configuration_error( assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {"base": "pubsub_api_error"} + # Reset the mock to allow success on the next attempt. + aioclient_mock.clear_requests() + auth.register_mock_requests() + mock_pubsub_api_responses.create_subscription_status = HTTPStatus.OK + mock_pubsub_api_responses.register_mock_requests() -@pytest.mark.parametrize( - ("sdm_managed_topic", "create_subscription_status"), - [(True, HTTPStatus.INTERNAL_SERVER_ERROR)], -) -async def test_config_flow_pubsub_subscriber_error( - hass: HomeAssistant, - oauth: OAuthFixture, -) -> None: - """Check full flow with a subscriber error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await oauth.async_app_creds_flow(result) - oauth.async_mock_refresh() - result = await oauth.async_configure(result, {"code": "1234"}) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub_topic" - assert result.get("data_schema")({}) == { - "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", - } - - # Select Pub/Sub topic the show available subscriptions (none) - result = await oauth.async_configure( - result, - { - "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", - }, - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub_topic_confirm" - assert not result.get("errors") - - result = await oauth.async_configure(result, {}) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub_subscription" - assert result.get("data_schema")({}) == { - "subscription_name": "create_new_subscription", - } - - # Failure when creating the subscription result = await oauth.async_configure( result, { "subscription_name": "create_new_subscription", }, ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == PROJECT_ID - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {"base": "pubsub_api_error"} + entry = oauth.get_config_entry() + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } @pytest.mark.parametrize( - ("nest_test_config", "sdm_managed_topic", "device_access_project_id"), - [(TEST_CONFIG_APP_CREDS, True, "project-id-2")], + ("nest_test_config", "sdm_managed_topic", "device_access_project_id", "topic_args"), + [ + ( + TEST_CONFIG_APP_CREDS, + True, + "project-id-2", + {"selected_topic": "projects/sdm-prod/topics/enterprise-project-id-2"}, + ), + ( + TEST_CONFIG_APP_CREDS, + False, + "project-id-2", + { + "selected_topic": "create_new_topic", + }, + ), + ], ) async def test_multiple_config_entries( - hass: HomeAssistant, oauth, setup_platform + hass: HomeAssistant, + oauth: OAuthFixture, + setup_platform: Callable[[], Awaitable[None]], + topic_args: dict[str, str], ) -> None: """Verify config flow can be started when existing config entry exists.""" await setup_platform() @@ -669,9 +890,14 @@ async def test_multiple_config_entries( await oauth.async_app_creds_flow(result, project_id="project-id-2") oauth.async_mock_refresh() result = await oauth.async_configure(result, user_input={}) - entry = await oauth.async_complete_pubsub_flow( - result, selected_topic="projects/sdm-prod/topics/enterprise-project-id-2" + result = await oauth.async_complete_pubsub_flow( + result, + **topic_args, ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == "project-id-2" + + entry = oauth.get_config_entry() assert entry.title == "Mock Title" assert "token" in entry.data @@ -680,7 +906,8 @@ async def test_multiple_config_entries( @pytest.mark.parametrize( - ("nest_test_config", "sdm_managed_topic"), [(TEST_CONFIG_APP_CREDS, True)] + ("nest_test_config", "sdm_managed_topic"), + [(TEST_CONFIG_APP_CREDS, True), (TEST_CONFIG_APP_CREDS, False)], ) async def test_duplicate_config_entries( hass: HomeAssistant, oauth, setup_platform @@ -707,7 +934,8 @@ async def test_duplicate_config_entries( @pytest.mark.parametrize( - ("nest_test_config", "sdm_managed_topic"), [(TEST_CONFIG_APP_CREDS, True)] + ("nest_test_config", "sdm_managed_topic"), + [(TEST_CONFIG_APP_CREDS, True), (TEST_CONFIG_APP_CREDS, False)], ) async def test_reauth_multiple_config_entries( hass: HomeAssistant, oauth, setup_platform, config_entry @@ -735,7 +963,9 @@ async def test_reauth_multiple_config_entries( await oauth.async_oauth_web_flow(result) oauth.async_mock_refresh() - await oauth.async_finish_setup(result) + result = await oauth.async_finish_setup(result) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" # Only reauth entry was updated, the other entry is preserved entries = hass.config_entries.async_entries(DOMAIN) @@ -760,58 +990,8 @@ async def test_reauth_multiple_config_entries( @pytest.mark.parametrize( - ("sdm_managed_topic", "create_subscription_status"), - [(True, HTTPStatus.UNAUTHORIZED)], -) -async def test_pubsub_subscription_auth_failure( - hass: HomeAssistant, oauth, mock_subscriber -) -> None: - """Check flow that creates a pub/sub subscription.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - await oauth.async_app_creds_flow(result) - oauth.async_mock_refresh() - result = await oauth.async_configure(result, {"code": "1234"}) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub_topic" - assert result.get("data_schema")({}) == { - "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", - } - - # Select Pub/Sub topic the show available subscriptions (none) - result = await oauth.async_configure( - result, - { - "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", - }, - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub_topic_confirm" - assert not result.get("errors") - - result = await oauth.async_configure(result, {}) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub_subscription" - assert result.get("data_schema")({}) == { - "subscription_name": "create_new_subscription", - } - - # Failure when creating the subscription - result = await oauth.async_configure( - result, - { - "subscription_name": "create_new_subscription", - }, - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub_subscription" - assert result.get("errors") == {"base": "pubsub_api_error"} - - -@pytest.mark.parametrize( - ("nest_test_config", "sdm_managed_topic"), [(TEST_CONFIG_APP_CREDS, True)] + ("nest_test_config", "sdm_managed_topic"), + [(TEST_CONFIG_APP_CREDS, True), (TEST_CONFIG_APP_CREDS, False)], ) async def test_pubsub_subscriber_config_entry_reauth( hass: HomeAssistant, @@ -827,7 +1007,11 @@ async def test_pubsub_subscriber_config_entry_reauth( oauth.async_mock_refresh() # Entering an updated access token refreshes the config entry. - entry = await oauth.async_finish_setup(result, {"code": "1234"}) + result = await oauth.async_finish_setup(result, {"code": "1234"}) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + entry = oauth.get_config_entry() entry.data["token"].pop("expires_at") assert entry.unique_id == PROJECT_ID assert entry.data["token"] == { @@ -867,9 +1051,13 @@ async def test_config_entry_title_from_home( oauth.async_mock_refresh() result = await oauth.async_configure(result, {"code": "1234"}) - entry = await oauth.async_complete_pubsub_flow( + result = await oauth.async_complete_pubsub_flow( result, selected_topic=f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}" ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == PROJECT_ID + + entry = oauth.get_config_entry() assert entry.title == "Example Home" assert "token" in entry.data assert entry.data.get("cloud_project_id") == CLOUD_PROJECT_ID @@ -883,11 +1071,18 @@ async def test_config_entry_title_from_home( ) -@pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) +@pytest.mark.parametrize( + ("sdm_managed_topic", "topic_args"), + [ + (True, {"selected_topic": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}"}), + (False, {"selected_topic": "create_new_topic"}), + ], +) async def test_config_entry_title_multiple_homes( hass: HomeAssistant, oauth: OAuthFixture, auth: FakeAuth, + topic_args: dict[str, str], ) -> None: """Test handling of multiple Google Homes authorized.""" auth.structures.extend( @@ -918,9 +1113,14 @@ async def test_config_entry_title_multiple_homes( oauth.async_mock_refresh() result = await oauth.async_configure(result, {"code": "1234"}) - entry = await oauth.async_complete_pubsub_flow( - result, selected_topic=f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}" + result = await oauth.async_complete_pubsub_flow( + result, + **topic_args, ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == PROJECT_ID + + entry = oauth.get_config_entry() assert entry.title == "Example Home #1, Example Home #2" @@ -938,10 +1138,13 @@ async def test_title_failure_fallback( mock_subscriber.async_get_device_manager.side_effect = AuthException() result = await oauth.async_configure(result, {"code": "1234"}) - entry = await oauth.async_complete_pubsub_flow( + result = await oauth.async_complete_pubsub_flow( result, selected_topic=f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}" ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == PROJECT_ID + entry = oauth.get_config_entry() assert entry.title == "Import from configuration.yaml" assert "token" in entry.data assert entry.data.get("cloud_project_id") == CLOUD_PROJECT_ID @@ -976,10 +1179,14 @@ async def test_structure_missing_trait( oauth.async_mock_refresh() result = await oauth.async_configure(result, {"code": "1234"}) - entry = await oauth.async_complete_pubsub_flow( + result = await oauth.async_complete_pubsub_flow( result, selected_topic=f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}" ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == PROJECT_ID + # Fallback to default name + entry = oauth.get_config_entry() assert entry.title == "Import from configuration.yaml" @@ -1024,10 +1231,92 @@ async def test_dhcp_discovery_already_setup( assert result["reason"] == "already_configured" -@pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) +@pytest.mark.parametrize( + ( + "sdm_managed_topic", + "user_managed_topics", + "subscriptions", + "topic_args", + "expected_config_entry_data", + ), + [ + ( + True, + [], # No existing topics + [], # No existing subscriptions + { + "selected_topic": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", + "selected_subscription": "create_new_subscription", + }, + { + "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + }, + ), + ( + True, + # Existing topics are ignored + [f"projects/{CLOUD_PROJECT_ID}/topics/some-topic-id"], + # Existing subscriptions are ignored + [ + ( + f"projects/{CLOUD_PROJECT_ID}/subscriptions/some-subscription-id", + f"projects/{CLOUD_PROJECT_ID}/topics/some-topic-id", + ) + ], + { + "selected_topic": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", + "selected_subscription": "create_new_subscription", + }, + { + "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + }, + ), + ( + False, + [], # No existing topics + [], # No existing subscriptions + { + "selected_topic": "create_new_topic", + }, + { + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/home-assistant-ABCDEF", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + }, + ), + ( + False, + [f"projects/{CLOUD_PROJECT_ID}/topics/some-topic-id"], + [ + ( + f"projects/{CLOUD_PROJECT_ID}/subscriptions/some-subscription-id", # Selected subscription + f"projects/{CLOUD_PROJECT_ID}/topics/some-topic-id", + ) + ], + { + "selected_topic": f"projects/{CLOUD_PROJECT_ID}/topics/some-topic-id", + "selected_subscription": f"projects/{CLOUD_PROJECT_ID}/subscriptions/some-subscription-id", + }, + { + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/some-topic-id", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/some-subscription-id", + "subscriber_id_imported": True, + }, + ), + ], + ids=[ + "sdm-managed-no-existing-subscriptions", + "sdm-managed-with-existing-subscriptions", + "user-managed-create-new-topic", + "user-managed-select-existing-subscription", + ], +) async def test_dhcp_discovery_with_creds( hass: HomeAssistant, oauth: OAuthFixture, + topic_args: dict[str, str], + expected_config_entry_data: dict[str, Any], ) -> None: """Exercise discovery dhcp with no config present (can't run).""" result = await hass.config_entries.flow.async_init( @@ -1051,11 +1340,12 @@ async def test_dhcp_discovery_with_creds( await oauth.async_oauth_web_flow(result) oauth.async_mock_refresh() - result = await oauth.async_configure(result, {"code": "1234"}) - entry = await oauth.async_complete_pubsub_flow( - result, selected_topic=f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}" - ) + result = await oauth.async_configure(result, None) + result = await oauth.async_complete_pubsub_flow(result, **topic_args) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == PROJECT_ID + entry = oauth.get_config_entry() data = dict(entry.data) assert "token" in data data["token"].pop("expires_in") @@ -1065,8 +1355,7 @@ async def test_dhcp_discovery_with_creds( "auth_implementation": "imported-cred", "cloud_project_id": CLOUD_PROJECT_ID, "project_id": PROJECT_ID, - "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", - "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", + **expected_config_entry_data, "token": { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", @@ -1130,12 +1419,15 @@ async def test_existing_topic_and_subscription( oauth.async_mock_refresh() result = await oauth.async_configure(result, None) - entry = await oauth.async_complete_pubsub_flow( + result = await oauth.async_complete_pubsub_flow( result, selected_topic=f"projects/{CLOUD_PROJECT_ID}/topics/some-topic-id", selected_subscription=f"projects/{CLOUD_PROJECT_ID}/subscriptions/some-subscription-id", ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == PROJECT_ID + entry = oauth.get_config_entry() data = dict(entry.data) assert "token" in data data["token"].pop("expires_in") @@ -1176,12 +1468,15 @@ async def test_no_eligible_topics( "topic_name": "create_new_topic", } - entry = await oauth.async_complete_pubsub_flow( + result = await oauth.async_complete_pubsub_flow( result, selected_topic="create_new_topic", selected_subscription="create_new_subscription", ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == PROJECT_ID + entry = oauth.get_config_entry() data = dict(entry.data) assert "token" in data data["token"].pop("expires_in") @@ -1201,17 +1496,19 @@ async def test_no_eligible_topics( } -@pytest.mark.parametrize( - ("list_topics_status"), - [ - (HTTPStatus.INTERNAL_SERVER_ERROR), - ], -) async def test_list_topics_failure( hass: HomeAssistant, oauth: OAuthFixture, + aioclient_mock: AiohttpClientMocker, + auth: FakeAuth, + mock_pubsub_api_responses: MockPubSubAPIResponses, ) -> None: """Test selecting existing user managed topic and subscription.""" + aioclient_mock.clear_requests() + auth.register_mock_requests() + mock_pubsub_api_responses.list_topics_status = HTTPStatus.INTERNAL_SERVER_ERROR + mock_pubsub_api_responses.register_mock_requests() + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -1223,10 +1520,6 @@ async def test_list_topics_failure( assert result.get("reason") == "pubsub_api_error" -@pytest.mark.parametrize( - ("create_topic_status"), - [(HTTPStatus.INTERNAL_SERVER_ERROR)], -) async def test_create_topic_failed( hass: HomeAssistant, oauth: OAuthFixture, @@ -1234,8 +1527,14 @@ async def test_create_topic_failed( cloud_project_id: str, subscriptions: list[tuple[str, str]], auth: FakeAuth, + mock_pubsub_api_responses: MockPubSubAPIResponses, ) -> None: """Test the case where there are no eligible pub/sub topics and the topic is created.""" + aioclient_mock.clear_requests() + auth.register_mock_requests() + mock_pubsub_api_responses.create_topic_status = HTTPStatus.INTERNAL_SERVER_ERROR + mock_pubsub_api_responses.register_mock_requests() + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -1280,10 +1579,14 @@ async def test_create_topic_failed( assert not result.get("errors") # Create a subscription for the topic and end the flow - entry = await oauth.async_finish_setup( + result = await oauth.async_finish_setup( result, {"subscription_name": "create_new_subscription"}, ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == PROJECT_ID + + entry = oauth.get_config_entry() data = dict(entry.data) assert "token" in data data["token"].pop("expires_in") @@ -1303,17 +1606,22 @@ async def test_create_topic_failed( } -@pytest.mark.parametrize( - ("sdm_managed_topic", "list_subscriptions_status"), - [ - (True, HTTPStatus.INTERNAL_SERVER_ERROR), - ], -) +@pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_list_subscriptions_failure( hass: HomeAssistant, oauth: OAuthFixture, + aioclient_mock: AiohttpClientMocker, + auth: FakeAuth, + mock_pubsub_api_responses: MockPubSubAPIResponses, ) -> None: """Test selecting existing user managed topic and subscription.""" + aioclient_mock.clear_requests() + auth.register_mock_requests() + mock_pubsub_api_responses.list_subscriptions_status = ( + HTTPStatus.INTERNAL_SERVER_ERROR + ) + mock_pubsub_api_responses.register_mock_requests() + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -1340,3 +1648,32 @@ async def test_list_subscriptions_failure( assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("errors") == {"base": "pubsub_api_error"} + + # Continue with the rest of the flow but clear the mocks so that the + # flow will now succeed. + aioclient_mock.clear_requests() + auth.register_mock_requests() + mock_pubsub_api_responses.register_mock_requests() + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("context", {}).get("unique_id") == PROJECT_ID + + entry = oauth.get_config_entry() + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + }