From 125bcf42e5d042e9df5afb2fc1b65e1301722937 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 20 Sep 2026 17:01:44 +0200 Subject: [PATCH] New: Threema Integration (#165993) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Norbert Rittel Co-authored-by: Joostlek --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/threema/__init__.py | 58 ++ homeassistant/components/threema/client.py | 45 ++ .../components/threema/config_flow.py | 276 +++++++ homeassistant/components/threema/const.py | 8 + .../components/threema/manifest.json | 11 + homeassistant/components/threema/notify.py | 71 ++ .../components/threema/quality_scale.yaml | 100 +++ homeassistant/components/threema/strings.json | 85 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + tests/components/threema/__init__.py | 1 + tests/components/threema/conftest.py | 97 +++ tests/components/threema/test_client.py | 64 ++ tests/components/threema/test_config_flow.py | 756 ++++++++++++++++++ tests/components/threema/test_init.py | 97 +++ tests/components/threema/test_notify.py | 245 ++++++ 20 files changed, 1937 insertions(+) create mode 100644 homeassistant/components/threema/__init__.py create mode 100644 homeassistant/components/threema/client.py create mode 100644 homeassistant/components/threema/config_flow.py create mode 100644 homeassistant/components/threema/const.py create mode 100644 homeassistant/components/threema/manifest.json create mode 100644 homeassistant/components/threema/notify.py create mode 100644 homeassistant/components/threema/quality_scale.yaml create mode 100644 homeassistant/components/threema/strings.json create mode 100644 tests/components/threema/__init__.py create mode 100644 tests/components/threema/conftest.py create mode 100644 tests/components/threema/test_client.py create mode 100644 tests/components/threema/test_config_flow.py create mode 100644 tests/components/threema/test_init.py create mode 100644 tests/components/threema/test_notify.py diff --git a/.strict-typing b/.strict-typing index d3213c0cd8e3..dfa00bad8522 100644 --- a/.strict-typing +++ b/.strict-typing @@ -603,6 +603,7 @@ homeassistant.components.teltonika.* homeassistant.components.teslemetry.* homeassistant.components.text.* homeassistant.components.thethingsnetwork.* +homeassistant.components.threema.* homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* diff --git a/CODEOWNERS b/CODEOWNERS index 111b8e4c7120..51831978cadf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1910,6 +1910,8 @@ CLAUDE.md @home-assistant/core /tests/components/thethingsnetwork/ @angelnu /homeassistant/components/thread/ @home-assistant/core /tests/components/thread/ @home-assistant/core +/homeassistant/components/threema/ @LukasQ +/tests/components/threema/ @LukasQ /homeassistant/components/tibber/ @danielhiversen /tests/components/tibber/ @danielhiversen /homeassistant/components/tile/ @bachya diff --git a/homeassistant/components/threema/__init__.py b/homeassistant/components/threema/__init__.py new file mode 100644 index 000000000000..6c934eb44c8a --- /dev/null +++ b/homeassistant/components/threema/__init__.py @@ -0,0 +1,58 @@ +"""The Threema Gateway integration.""" + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .client import ThreemaAPIClient, ThreemaAuthError, ThreemaConnectionError +from .const import CONF_API_SECRET, CONF_GATEWAY_ID, CONF_PRIVATE_KEY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.NOTIFY] + +type ThreemaConfigEntry = ConfigEntry[ThreemaAPIClient] + + +async def async_setup_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> bool: + """Set up Threema Gateway from a config entry.""" + client = ThreemaAPIClient( + hass, + gateway_id=entry.data[CONF_GATEWAY_ID], + api_secret=entry.data[CONF_API_SECRET], + private_key=entry.data.get(CONF_PRIVATE_KEY), + ) + + try: + await client.validate_credentials() + except ThreemaAuthError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except ThreemaConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + return True + + +async def _async_update_listener( + hass: HomeAssistant, entry: ThreemaConfigEntry +) -> None: + """Reload entry when config is updated (e.g. subentry added/removed).""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ThreemaConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/threema/client.py b/homeassistant/components/threema/client.py new file mode 100644 index 000000000000..bd4d5bbf73f0 --- /dev/null +++ b/homeassistant/components/threema/client.py @@ -0,0 +1,45 @@ +"""Threema Gateway API client, wired to Home Assistant's shared aiohttp session.""" + +from aiothreema import ( + ThreemaAuthError, + ThreemaConnectionError, + ThreemaGatewayClient, + ThreemaSendError, + derive_public_key, + generate_key_pair, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +__all__ = [ + "ThreemaAPIClient", + "ThreemaAuthError", + "ThreemaConnectionError", + "ThreemaSendError", + "derive_public_key", + "generate_key_pair", +] + + +class ThreemaAPIClient(ThreemaGatewayClient): + """Threema Gateway client bound to Home Assistant's shared aiohttp session. + + The Gateway HTTP protocol and end-to-end encryption live in the + `aiothreema` library; this class only wires it up to Home Assistant. + """ + + def __init__( + self, + hass: HomeAssistant, + gateway_id: str, + api_secret: str, + private_key: str | None = None, + ) -> None: + """Initialize the client with Home Assistant's shared session.""" + super().__init__( + gateway_id, + api_secret, + private_key, + session=async_get_clientsession(hass), + ) diff --git a/homeassistant/components/threema/config_flow.py b/homeassistant/components/threema/config_flow.py new file mode 100644 index 000000000000..efe3f49d70dd --- /dev/null +++ b/homeassistant/components/threema/config_flow.py @@ -0,0 +1,276 @@ +"""Config flow for Threema Gateway integration.""" + +import logging +import re +from typing import Any, override + +import probatio + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_NAME, CONF_RECIPIENT +from homeassistant.core import callback +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .client import ( + ThreemaAPIClient, + ThreemaAuthError, + ThreemaConnectionError, + derive_public_key, + generate_key_pair, +) +from .const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + CONF_PRIVATE_KEY, + DOMAIN, + SUBENTRY_TYPE_RECIPIENT, +) + +_LOGGER = logging.getLogger(__name__) + +_KEY_HEX_LENGTH = 64 +_KEY_PREFIXES = ("private:", "public:") +_CONF_PUBLIC_KEY = "public_key" +_GATEWAY_ID_REGEX = re.compile(r"^\*[A-Z0-9]{7}$") + + +def _strip_key_prefix(value: str, expected_prefix: str) -> str | None: + """Strip the expected Threema key-export prefix, if present. + + Returns None if the value carries a *different* key-type prefix (e.g. + a 'public:' key pasted into the private-key field), so the mismatch + can be rejected instead of silently accepted as the wrong key type. + """ + lowered = value.lower() + for prefix in _KEY_PREFIXES: + if lowered.startswith(prefix): + if prefix != expected_prefix: + return None + return value[len(prefix) :].strip() + return value + + +def _is_valid_key_hex(value: str) -> bool: + """Return True if value is a 64-character hex string (32-byte NaCl key).""" + if len(value) != _KEY_HEX_LENGTH: + return False + try: + bytes.fromhex(value) + except ValueError: + return False + return True + + +class ThreemaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Threema Gateway.""" + + VERSION = 1 + MINOR_VERSION = 1 + + @classmethod + @callback + @override + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentry types supported by this integration.""" + return {SUBENTRY_TYPE_RECIPIENT: RecipientSubentryFlowHandler} + + _gateway_id: str | None = None + _api_secret: str | None = None + _private_key: str | None = None + _public_key: str | None = None + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step - choose setup type.""" + return self.async_show_menu( + step_id="user", + menu_options=["credentials", "setup_new"], + ) + + async def async_step_setup_new( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Generate keys for a new Gateway ID.""" + if user_input is not None: + return await self.async_step_credentials() + + try: + private_key, public_key = await self.hass.async_add_executor_job( + generate_key_pair + ) + except Exception: + _LOGGER.exception("Failed to generate key pair") + return self.async_abort(reason="key_generation_failed") + + self._private_key = private_key + + return self.async_show_form( + step_id="setup_new", + description_placeholders={ + "public_key": public_key, + "private_key": private_key, + }, + ) + + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Collect Gateway credentials.""" + errors: dict[str, str] = {} + + if user_input is not None: + gateway_id = user_input[CONF_GATEWAY_ID].strip().upper() + + if not _GATEWAY_ID_REGEX.match(gateway_id): + errors["base"] = "invalid_gateway_id" + else: + await self.async_set_unique_id(gateway_id) + self._abort_if_unique_id_configured() + + self._gateway_id = gateway_id + self._api_secret = user_input[CONF_API_SECRET].strip() + + raw_private_key = user_input.get(CONF_PRIVATE_KEY, "").strip() + self._private_key = raw_private_key or None + raw_public_key = user_input.get(_CONF_PUBLIC_KEY, "").strip() + self._public_key = raw_public_key or None + + private_key = ( + _strip_key_prefix(raw_private_key, "private:") + if raw_private_key + else None + ) + public_key = ( + _strip_key_prefix(raw_public_key, "public:") + if raw_public_key + else None + ) + + if raw_private_key and private_key is None: + errors[CONF_PRIVATE_KEY] = "invalid_key" + elif raw_public_key and public_key is None: + errors[_CONF_PUBLIC_KEY] = "invalid_key" + elif private_key and not _is_valid_key_hex(private_key): + errors[CONF_PRIVATE_KEY] = "invalid_key" + elif public_key and not _is_valid_key_hex(public_key): + errors[_CONF_PUBLIC_KEY] = "invalid_key" + elif public_key and not private_key: + errors[_CONF_PUBLIC_KEY] = "public_key_requires_private_key" + elif ( + private_key + and public_key + and derive_public_key(private_key).lower() != public_key.lower() + ): + errors[_CONF_PUBLIC_KEY] = "key_mismatch" + else: + client = ThreemaAPIClient( + self.hass, + gateway_id=gateway_id, + api_secret=self._api_secret, + private_key=private_key, + ) + + try: + await client.validate_credentials() + except ThreemaAuthError: + errors["base"] = "invalid_auth" + except ThreemaConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error validating credentials") + errors["base"] = "unknown" + else: + data: dict[str, str] = { + CONF_GATEWAY_ID: self._gateway_id, + CONF_API_SECRET: self._api_secret, + } + if private_key: + data[CONF_PRIVATE_KEY] = private_key + + return self.async_create_entry( + title=f"Threema {self._gateway_id}", + data=data, + ) + + schema = probatio.Schema( + { + probatio.Required(CONF_GATEWAY_ID, default=self._gateway_id or ""): str, + probatio.Required( + CONF_API_SECRET, default=self._api_secret or "" + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + probatio.Optional( + CONF_PRIVATE_KEY, default=self._private_key or "" + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + probatio.Optional( + _CONF_PUBLIC_KEY, default=self._public_key or "" + ): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)), + } + ) + + return self.async_show_form( + step_id="credentials", + data_schema=schema, + errors=errors, + ) + + +_RECIPIENT_ID_REGEX = re.compile(r"^[0-9A-Za-z]{8}$") + + +class RecipientSubentryFlowHandler(ConfigSubentryFlow): + """Handle adding a Threema recipient as a subentry.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the recipient subentry step.""" + errors: dict[str, str] = {} + + if user_input is not None: + recipient_id = user_input[CONF_RECIPIENT].strip().upper() + + if not _RECIPIENT_ID_REGEX.match(recipient_id): + errors[CONF_RECIPIENT] = "invalid_recipient_id" + else: + # Check for duplicate recipients + for subentry in self._get_entry().subentries.values(): + if subentry.data.get(CONF_RECIPIENT) == recipient_id: + return self.async_abort(reason="already_configured") + + raw_name = user_input.get(CONF_NAME, "").strip() + title = f"{raw_name} ({recipient_id})" if raw_name else recipient_id + + data: dict[str, str] = {CONF_RECIPIENT: recipient_id} + if raw_name: + data[CONF_NAME] = raw_name + + return self.async_create_entry( + title=title, + data=data, + unique_id=recipient_id, + ) + + return self.async_show_form( + step_id="user", + data_schema=probatio.Schema( + { + probatio.Required(CONF_RECIPIENT): str, + probatio.Optional(CONF_NAME): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/threema/const.py b/homeassistant/components/threema/const.py new file mode 100644 index 000000000000..7fa164df7120 --- /dev/null +++ b/homeassistant/components/threema/const.py @@ -0,0 +1,8 @@ +"""Constants for the Threema Gateway integration.""" + +DOMAIN = "threema" + +CONF_GATEWAY_ID = "gateway_id" +CONF_API_SECRET = "api_secret" +CONF_PRIVATE_KEY = "private_key" +SUBENTRY_TYPE_RECIPIENT = "recipient" diff --git a/homeassistant/components/threema/manifest.json b/homeassistant/components/threema/manifest.json new file mode 100644 index 000000000000..06c3fabdabe7 --- /dev/null +++ b/homeassistant/components/threema/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "threema", + "name": "Threema", + "codeowners": ["@LukasQ"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/threema", + "integration_type": "service", + "iot_class": "cloud_push", + "quality_scale": "bronze", + "requirements": ["aiothreema==0.1.0"] +} diff --git a/homeassistant/components/threema/notify.py b/homeassistant/components/threema/notify.py new file mode 100644 index 000000000000..6fd24a3e5793 --- /dev/null +++ b/homeassistant/components/threema/notify.py @@ -0,0 +1,71 @@ +"""Notify platform for Threema Gateway integration.""" + +from typing import override + +from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_RECIPIENT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ThreemaConfigEntry +from .client import ThreemaAuthError, ThreemaConnectionError, ThreemaSendError +from .const import DOMAIN, SUBENTRY_TYPE_RECIPIENT + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThreemaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Threema notify entities from config entry subentries.""" + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_RECIPIENT): + async_add_entities( + [ThreemaNotifyEntity(entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class ThreemaNotifyEntity(NotifyEntity): + """Notify entity for sending messages to a Threema recipient.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_supported_features = NotifyEntityFeature.TITLE + + def __init__( + self, + entry: ThreemaConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the notify entity.""" + self._client = entry.runtime_data + self._recipient_id: str = subentry.data[CONF_RECIPIENT] + + self._attr_unique_id = f"{self._client.gateway_id}_{self._recipient_id}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="Threema", + identifiers={(DOMAIN, self._attr_unique_id)}, + name=subentry.title, + ) + + @override + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message to the configured Threema recipient.""" + text = f"*{title}*\n{message}" if title else message + try: + await self._client.send_text_message(self._recipient_id, text) + except ThreemaAuthError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except (ThreemaSendError, ThreemaConnectionError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/threema/quality_scale.yaml b/homeassistant/components/threema/quality_scale.yaml new file mode 100644 index 000000000000..d1ee16ff5caa --- /dev/null +++ b/homeassistant/components/threema/quality_scale.yaml @@ -0,0 +1,100 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: only entity actions + appropriate-polling: + status: exempt + comment: Integration only sends messages, no polling required. + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: done + docs-conditions: + status: exempt + comment: Integration does not provide any automation conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: Integration does not provide any automation triggers. + entity-event-setup: + status: exempt + comment: Integration does not use event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: Notify entities are stateless send-only, availability is not applicable. + integration-owner: done + log-when-unavailable: + status: exempt + comment: Notify entities are stateless send-only. + parallel-updates: + status: exempt + comment: No polling or data updates. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: Cloud service, not discoverable on the network. + discovery-update-info: + status: exempt + comment: Cloud service, not discoverable on the network. + docs-data-update: + status: exempt + comment: Integration does not poll or update data. + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: Service integration, no physical devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: No devices in current scope. + entity-category: + status: exempt + comment: Notify entities have no applicable category. + entity-device-class: + status: exempt + comment: No applicable device class for notify entities. + entity-disabled-by-default: + status: exempt + comment: Notify entities should be enabled by default for immediate use. + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: Notify entities use default platform icon. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No scenarios requiring repair issues. + stale-devices: + status: exempt + comment: No devices in current scope. + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/threema/strings.json b/homeassistant/components/threema/strings.json new file mode 100644 index 000000000000..0fe869355420 --- /dev/null +++ b/homeassistant/components/threema/strings.json @@ -0,0 +1,85 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "key_generation_failed": "Failed to generate encryption keys." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_gateway_id": "Gateway ID must start with * followed by 7 alphanumeric characters.", + "invalid_key": "Key must be a 64-character hex string (32 bytes).", + "key_mismatch": "The public key does not match the private key.", + "public_key_requires_private_key": "Enter the private key too to verify it against the public key.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "credentials": { + "data": { + "api_secret": "API secret", + "gateway_id": "Gateway ID", + "private_key": "Private key (optional, enables E2E encryption)", + "public_key": "Public key to verify against (optional)" + }, + "data_description": { + "api_secret": "Secret you received from Threema", + "gateway_id": "ID of your Gateway, including '*', e.g.: *ABCD123", + "private_key": "Private key as hex string (64 characters). A 'private:' prefix, as used by Threema's key export tools, is accepted and stripped automatically. Leave empty for simple (non-E2E) mode.", + "public_key": "Never stored — only checked once against the private key above to catch copy-paste mistakes. Paste the public key you registered at gateway.threema.ch (a 'public:' prefix is accepted)." + }, + "description": "Enter your Threema Gateway ID and secret obtained from gateway.threema.ch. Threema enables end-to-end mode for a Gateway ID manually after you register a public key, which can take a few days — so you may well be completing this step long after the keys were generated.", + "title": "Gateway credentials" + }, + "setup_new": { + "description": "Your keys have been generated. Save them now before continuing!\n\nPublic key: `{public_key}`\nPrivate key: `{private_key}`\n\nWhen registering at gateway.threema.ch, paste the public key as shown above. Store the private key securely — it cannot be recovered.", + "title": "Keys generated" + }, + "user": { + "menu_options": { + "credentials": "Add existing Gateway ID", + "setup_new": "Generate new encryption keys" + }, + "title": "Threema setup" + } + } + }, + "config_subentries": { + "recipient": { + "abort": { + "already_configured": "This recipient is already configured." + }, + "entry_type": "Recipient", + "error": { + "invalid_recipient_id": "Threema ID must be exactly 8 alphanumeric characters." + }, + "initiate_flow": { + "user": "Add recipient" + }, + "step": { + "user": { + "data": { + "name": "Display name", + "recipient": "Threema ID" + }, + "data_description": { + "name": "Optional friendly name for this recipient (e.g. 'Dad').", + "recipient": "The 8-character Threema ID of the recipient." + }, + "description": "Enter the Threema ID of the person you want to send messages to.", + "title": "Add recipient" + } + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "invalid_auth": { + "message": "[%key:common::config_flow::error::invalid_auth%]" + }, + "send_error": { + "message": "Error sending message: {error}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b09efe44e6b1..02320c461165 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -810,6 +810,7 @@ FLOWS = { "thermopro", "thethingsnetwork", "thread", + "threema", "tibber", "tile", "tilt_ble", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 62262e0f421a..de86b897ecda 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7504,6 +7504,12 @@ "iot_class": "local_polling", "single_config_entry": true }, + "threema": { + "name": "Threema", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_push" + }, "tibber": { "name": "Tibber", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 489d9261dafe..c7660967c1d5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5790,6 +5790,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.threema.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.threshold.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 5a22f56c5a5a..68d1fc739e1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -470,6 +470,9 @@ aiotankerkoenig==0.5.3 # homeassistant.components.tedee aiotedee==0.3.0 +# homeassistant.components.threema +aiothreema==0.1.0 + # homeassistant.components.tractive aiotractive==1.0.3 diff --git a/tests/components/threema/__init__.py b/tests/components/threema/__init__.py new file mode 100644 index 000000000000..a7bc4a6805f0 --- /dev/null +++ b/tests/components/threema/__init__.py @@ -0,0 +1 @@ +"""Tests for the Threema Gateway integration.""" diff --git a/tests/components/threema/conftest.py b/tests/components/threema/conftest.py new file mode 100644 index 000000000000..3b97f71a16e3 --- /dev/null +++ b/tests/components/threema/conftest.py @@ -0,0 +1,97 @@ +"""Fixtures for Threema Gateway integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.threema.const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + CONF_PRIVATE_KEY, + DOMAIN, + SUBENTRY_TYPE_RECIPIENT, +) +from homeassistant.config_entries import ConfigSubentryDataWithId +from homeassistant.const import CONF_RECIPIENT + +from tests.common import MockConfigEntry + +MOCK_GATEWAY_ID = "*TESTGWY" +MOCK_API_SECRET = "test_secret_key_12345" +MOCK_PRIVATE_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +MOCK_PUBLIC_KEY = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" +MOCK_RECIPIENT_ID = "ABCD1234" +MOCK_SUBENTRY_ID = "mock_subentry_id" + +RECIPIENT_SUBENTRY: ConfigSubentryDataWithId = { + "data": {CONF_RECIPIENT: MOCK_RECIPIENT_ID}, + "subentry_id": MOCK_SUBENTRY_ID, + "subentry_type": SUBENTRY_TYPE_RECIPIENT, + "title": MOCK_RECIPIENT_ID, + "unique_id": MOCK_RECIPIENT_ID, +} + + +@pytest.fixture +def mock_subentries() -> list[ConfigSubentryDataWithId]: + """Fixture providing one recipient subentry by default; override to [] when not needed.""" + return [RECIPIENT_SUBENTRY] + + +@pytest.fixture +def mock_config_entry( + mock_subentries: list[ConfigSubentryDataWithId], +) -> MockConfigEntry: + """Return a mocked config entry for basic mode (no encryption).""" + return MockConfigEntry( + title=f"Threema {MOCK_GATEWAY_ID}", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + unique_id=MOCK_GATEWAY_ID, + subentries_data=[*mock_subentries], + ) + + +@pytest.fixture +def mock_config_entry_with_keys( + mock_subentries: list[ConfigSubentryDataWithId], +) -> MockConfigEntry: + """Return a mocked config entry for E2E encrypted mode.""" + return MockConfigEntry( + title=f"Threema {MOCK_GATEWAY_ID}", + domain=DOMAIN, + data={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: MOCK_PRIVATE_KEY, + }, + unique_id=MOCK_GATEWAY_ID, + subentries_data=[*mock_subentries], + ) + + +@pytest.fixture +def mock_credentials() -> Generator[AsyncMock]: + """Mock ThreemaAPIClient.validate_credentials to succeed by default.""" + with patch( + "homeassistant.components.threema.client.ThreemaAPIClient.validate_credentials", + new_callable=AsyncMock, + ) as mock: + yield mock + + +@pytest.fixture +def mock_send_message() -> Generator[AsyncMock]: + """Mock ThreemaAPIClient.send_text_message to return a message ID.""" + with patch( + "homeassistant.components.threema.client.ThreemaAPIClient.send_text_message", + new_callable=AsyncMock, + return_value="mock_message_id", + ) as mock: + yield mock diff --git a/tests/components/threema/test_client.py b/tests/components/threema/test_client.py new file mode 100644 index 000000000000..3d7e39486bcf --- /dev/null +++ b/tests/components/threema/test_client.py @@ -0,0 +1,64 @@ +"""Unit tests for the Threema Gateway API client wrapper. + +`ThreemaAPIClient` only wires Home Assistant's shared aiohttp session into +`aiothreema.ThreemaGatewayClient`. The Gateway HTTP protocol, encryption, +and key generation are implemented and tested in that library itself, so +this file only tests the wiring this integration owns. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from aiothreema import ThreemaGatewayClient + +from homeassistant.components.threema.client import ThreemaAPIClient +from homeassistant.core import HomeAssistant + +from .conftest import MOCK_API_SECRET, MOCK_GATEWAY_ID, MOCK_PRIVATE_KEY + + +def _patch_session(session: MagicMock): + """Return a context manager that patches async_get_clientsession.""" + return patch( + "homeassistant.components.threema.client.async_get_clientsession", + return_value=session, + ) + + +def test_is_a_threema_gateway_client(hass: HomeAssistant) -> None: + """Test the wrapper is usable wherever a ThreemaGatewayClient is expected.""" + with _patch_session(MagicMock()): + client = ThreemaAPIClient(hass, MOCK_GATEWAY_ID, MOCK_API_SECRET) + assert isinstance(client, ThreemaGatewayClient) + + +def test_uses_home_assistants_shared_session(hass: HomeAssistant) -> None: + """Test the client is wired to Home Assistant's shared aiohttp session.""" + mock_session = MagicMock() + with patch( + "homeassistant.components.threema.client.async_get_clientsession", + return_value=mock_session, + ) as mock_get_session: + client = ThreemaAPIClient(hass, MOCK_GATEWAY_ID, MOCK_API_SECRET) + + mock_get_session.assert_called_once_with(hass) + assert client._session is mock_session + + +def test_passes_through_credentials(hass: HomeAssistant) -> None: + """Test gateway id, API secret, and private key are passed through as-is.""" + with _patch_session(MagicMock()): + client = ThreemaAPIClient( + hass, MOCK_GATEWAY_ID, MOCK_API_SECRET, private_key=MOCK_PRIVATE_KEY + ) + assert client.gateway_id == MOCK_GATEWAY_ID + assert client.api_secret == MOCK_API_SECRET + assert client.private_key == MOCK_PRIVATE_KEY + + +def test_private_key_defaults_to_none(hass: HomeAssistant) -> None: + """Test private_key defaults to None for simple (non-E2E) mode.""" + with _patch_session(MagicMock()): + client = ThreemaAPIClient(hass, MOCK_GATEWAY_ID, MOCK_API_SECRET) + assert client.private_key is None diff --git a/tests/components/threema/test_config_flow.py b/tests/components/threema/test_config_flow.py new file mode 100644 index 000000000000..0d8e74d95cd1 --- /dev/null +++ b/tests/components/threema/test_config_flow.py @@ -0,0 +1,756 @@ +"""Test the Threema Gateway config flow.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import probatio +import pytest + +from homeassistant import config_entries +from homeassistant.components.threema.client import ( + ThreemaAuthError, + ThreemaConnectionError, + derive_public_key, +) +from homeassistant.components.threema.config_flow import _CONF_PUBLIC_KEY +from homeassistant.components.threema.const import ( + CONF_API_SECRET, + CONF_GATEWAY_ID, + CONF_PRIVATE_KEY, + DOMAIN, + SUBENTRY_TYPE_RECIPIENT, +) +from homeassistant.const import CONF_NAME, CONF_RECIPIENT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_API_SECRET, MOCK_GATEWAY_ID, MOCK_RECIPIENT_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator[None]: + """Patch async_setup_entry to avoid full setup during flow tests.""" + with patch("homeassistant.components.threema.async_setup_entry", return_value=True): + yield + + +async def test_user_flow_existing_gateway( + hass: HomeAssistant, mock_credentials: AsyncMock +) -> None: + """Test user flow with existing gateway credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "credentials" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Threema {MOCK_GATEWAY_ID}" + assert result["data"] == { + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + } + assert result["result"].unique_id == MOCK_GATEWAY_ID + + +async def test_user_flow_existing_with_keys( + hass: HomeAssistant, mock_credentials: AsyncMock +) -> None: + """Test user flow with existing gateway including optional private key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["data"][CONF_PRIVATE_KEY] + == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ) + assert result["result"].unique_id == MOCK_GATEWAY_ID + + +async def test_user_flow_new_gateway( + hass: HomeAssistant, mock_credentials: AsyncMock +) -> None: + """Test user flow with new gateway (key generation).""" + generated_private_key = "0" * 64 + generated_public_key = "f" * 64 + + with patch( + "homeassistant.components.threema.config_flow.generate_key_pair", + return_value=(generated_private_key, generated_public_key), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "setup_new"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "setup_new" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "credentials" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: generated_private_key, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PRIVATE_KEY] == generated_private_key + assert result["result"].unique_id == MOCK_GATEWAY_ID + + +async def test_user_flow_key_generation_failure(hass: HomeAssistant) -> None: + """Test user flow aborts when key generation fails.""" + with patch( + "homeassistant.components.threema.config_flow.generate_key_pair", + side_effect=RuntimeError("Key generation failed"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "setup_new"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "key_generation_failed" + + +async def test_credentials_invalid_gateway_id( + hass: HomeAssistant, mock_credentials: AsyncMock +) -> None: + """Test credentials step with invalid Gateway ID.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + # Gateway ID not starting with * + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: "TESTGWY1", + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_gateway_id"} + + # Gateway ID wrong length + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: "*TEST", + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_gateway_id"} + + # Right length and prefix, but invalid characters + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: "*!!!!!!!", + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_gateway_id"} + + # Valid Gateway ID — recover and create entry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == MOCK_GATEWAY_ID + + +@pytest.mark.parametrize( + "invalid_key", + ["0123456789abcdef", "g" * 64], + ids=["wrong_length", "non_hex"], +) +async def test_credentials_invalid_private_key( + hass: HomeAssistant, + mock_credentials: AsyncMock, + invalid_key: str, +) -> None: + """Test credentials step rejects a malformed private key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: invalid_key, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_PRIVATE_KEY: "invalid_key"} + + # Recover by clearing the invalid key (the field defaults to the + # previous, invalid value, so it must be explicitly cleared) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + "prefixed_key", + [ + "private:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "PRIVATE:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ], + ids=["lower", "upper"], +) +async def test_credentials_private_key_prefix_stripped( + hass: HomeAssistant, + mock_credentials: AsyncMock, + prefixed_key: str, +) -> None: + """Test a 'private:'-prefixed key (as exported by Threema's tools) is accepted.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: prefixed_key, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["data"][CONF_PRIVATE_KEY] + == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ) + + +async def test_credentials_public_key_in_private_key_field_rejected( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test a 'public:'-prefixed key pasted into the private-key field is rejected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: f"public:{'a' * 64}", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_PRIVATE_KEY: "invalid_key"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "a" * 64, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_credentials_private_key_in_public_key_field_rejected( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test a 'private:'-prefixed key pasted into the public-key field is rejected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "a" * 64, + _CONF_PUBLIC_KEY: f"private:{'b' * 64}", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {_CONF_PUBLIC_KEY: "invalid_key"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "a" * 64, + _CONF_PUBLIC_KEY: "", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_credentials_public_key_without_private_key_rejected( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test a public key given alone (no private key) is rejected, not discarded.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + _CONF_PUBLIC_KEY: "a" * 64, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {_CONF_PUBLIC_KEY: "public_key_requires_private_key"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + _CONF_PUBLIC_KEY: "", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_credentials_public_key_matches( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test a public key matching the private key is accepted and not stored.""" + private_key = "1" * 64 + matching_public_key = derive_public_key(private_key) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: private_key, + _CONF_PUBLIC_KEY: f"public:{matching_public_key}", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PRIVATE_KEY] == private_key + assert _CONF_PUBLIC_KEY not in result["data"] + + +async def test_credentials_public_key_mismatch( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test a public key that does not match the private key is rejected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "1" * 64, + _CONF_PUBLIC_KEY: "f" * 64, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {_CONF_PUBLIC_KEY: "key_mismatch"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "1" * 64, + _CONF_PUBLIC_KEY: "", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_credentials_public_key_invalid_hex( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test a malformed public key is rejected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "1" * 64, + _CONF_PUBLIC_KEY: "not-a-valid-key", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {_CONF_PUBLIC_KEY: "invalid_key"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "1" * 64, + _CONF_PUBLIC_KEY: "", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_credentials_invalid_private_key_preserves_other_fields( + hass: HomeAssistant, + mock_credentials: AsyncMock, +) -> None: + """Test gateway ID and API secret stay filled in after an invalid key error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + CONF_PRIVATE_KEY: "not-a-valid-key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_PRIVATE_KEY: "invalid_key"} + + defaults = { + schema_key: schema_key.default() + for schema_key in result["data_schema"].schema + if schema_key.default is not probatio.UNDEFINED + } + assert defaults[CONF_GATEWAY_ID] == MOCK_GATEWAY_ID + assert defaults[CONF_API_SECRET] == MOCK_API_SECRET + + +async def test_credentials_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, +) -> None: + """Test credentials step when gateway is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (ThreemaConnectionError("Connection refused"), "cannot_connect"), + (ThreemaConnectionError("Server error"), "cannot_connect"), + (ThreemaAuthError("Invalid credentials"), "invalid_auth"), + (RuntimeError("Unexpected"), "unknown"), + ], + ids=["cannot_connect", "server_error_non_auth", "invalid_auth", "unknown_error"], +) +async def test_credentials_error( + hass: HomeAssistant, + mock_credentials: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test credentials step with various errors.""" + mock_credentials.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "credentials"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_credentials.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_GATEWAY_ID: MOCK_GATEWAY_ID, + CONF_API_SECRET: MOCK_API_SECRET, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_subentry_add_recipient( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, +) -> None: + """Test adding a recipient via subentry flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "EFGH5678"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "EFGH5678" + assert result["data"] == {CONF_RECIPIENT: "EFGH5678"} + assert result["unique_id"] == "EFGH5678" + + +async def test_subentry_add_recipient_with_name( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, +) -> None: + """Test adding a recipient with a display name.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "EFGH5678", "name": "Dad"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Dad (EFGH5678)" + assert result["data"] == {CONF_RECIPIENT: "EFGH5678", CONF_NAME: "Dad"} + assert result["unique_id"] == "EFGH5678" + + +@pytest.mark.parametrize( + "invalid_id", + ["ABC", "ABCD!@#$", ""], + ids=["too_short", "special_chars", "empty"], +) +async def test_subentry_invalid_recipient_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + invalid_id: str, +) -> None: + """Test subentry flow rejects invalid Threema ID with an inline form error.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: invalid_id}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_RECIPIENT: "invalid_recipient_id"} + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: "EFGH5678"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_subentry_duplicate_recipient( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, +) -> None: + """Test subentry flow rejects duplicate recipient.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_RECIPIENT), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={CONF_RECIPIENT: MOCK_RECIPIENT_ID}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/threema/test_init.py b/tests/components/threema/test_init.py new file mode 100644 index 000000000000..c6fab5551d8d --- /dev/null +++ b/tests/components/threema/test_init.py @@ -0,0 +1,97 @@ +"""Test the Threema Gateway integration setup.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.threema.client import ( + ThreemaAuthError, + ThreemaConnectionError, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, +) -> None: + """Test successful setup of a config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (ThreemaConnectionError("Connection refused"), ConfigEntryState.SETUP_RETRY), + (ThreemaAuthError("Invalid credentials"), ConfigEntryState.SETUP_ERROR), + (ThreemaConnectionError("Server error"), ConfigEntryState.SETUP_RETRY), + ], + ids=["connection_error", "auth_error", "server_error_non_auth"], +) +async def test_setup_entry_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup handles various errors correctly.""" + mock_config_entry.add_to_hass(hass) + mock_credentials.side_effect = side_effect + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, +) -> None: + """Test unloading a config entry.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_update_listener_reloads( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, +) -> None: + """Test that update listener reloads the entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as mock_reload: + mock_reload.return_value = None + hass.config_entries.async_update_entry(mock_config_entry, title="Updated") + await hass.async_block_till_done() + + mock_reload.assert_called_once_with(mock_config_entry.entry_id) diff --git a/tests/components/threema/test_notify.py b/tests/components/threema/test_notify.py new file mode 100644 index 000000000000..e501485e322c --- /dev/null +++ b/tests/components/threema/test_notify.py @@ -0,0 +1,245 @@ +"""Test the Threema Gateway notify platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.threema.client import ( + ThreemaAuthError, + ThreemaConnectionError, + ThreemaSendError, +) +from homeassistant.components.threema.const import SUBENTRY_TYPE_RECIPIENT +from homeassistant.config_entries import ConfigSubentryDataWithId +from homeassistant.const import CONF_RECIPIENT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .conftest import MOCK_GATEWAY_ID, MOCK_RECIPIENT_ID, RECIPIENT_SUBENTRY + +from tests.common import MockConfigEntry + +_SECOND_RECIPIENT_ID = "WXYZ9999" +_SECOND_RECIPIENT_SUBENTRY: ConfigSubentryDataWithId = { + "data": {CONF_RECIPIENT: _SECOND_RECIPIENT_ID}, + "subentry_id": "second_recipient_subentry_id", + "subentry_type": SUBENTRY_TYPE_RECIPIENT, + "title": "Second recipient", + "unique_id": _SECOND_RECIPIENT_ID, +} + + +async def test_notify_entity_created( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test notify entity is created from subentry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + assert notify_entities[0].unique_id == f"{MOCK_GATEWAY_ID}_{MOCK_RECIPIENT_ID}" + + +@pytest.mark.parametrize( + "mock_subentries", [[RECIPIENT_SUBENTRY, _SECOND_RECIPIENT_SUBENTRY]] +) +async def test_notify_entities_get_separate_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test each recipient gets its own device, not a shared one. + + A device can only belong to a single config subentry; sharing one + across recipient subentries makes Home Assistant silently reassign + it whenever a new recipient is added, orphaning the previous one. + """ + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 2 + + device_ids = {e.device_id for e in notify_entities} + assert len(device_ids) == 2 + for device_id in device_ids: + assert device_id is not None + device = device_registry.async_get(device_id) + assert device is not None + assert device.config_entry_id == mock_config_entry.entry_id + assert device.config_subentry_id is not None + + +@pytest.mark.parametrize("mock_subentries", [[]]) +async def test_notify_entity_not_created_without_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test no notify entity without subentries.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 0 + + +async def test_send_message_simple( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test sending a message via notify entity (simple mode).""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + {"entity_id": notify_entities[0].entity_id, "message": "Hello from tests!"}, + blocking=True, + ) + + mock_send_message.assert_called_once_with(MOCK_RECIPIENT_ID, "Hello from tests!") + + +async def test_send_message_with_title( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test a title is formatted as a leading bold line in the sent text.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + { + "entity_id": notify_entities[0].entity_id, + "message": "Hello from tests!", + "title": "My Title", + }, + blocking=True, + ) + + mock_send_message.assert_called_once_with( + MOCK_RECIPIENT_ID, "*My Title*\nHello from tests!" + ) + + +async def test_send_message_e2e( + hass: HomeAssistant, + mock_config_entry_with_keys: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test sending a message via notify entity (E2E mode).""" + mock_config_entry_with_keys.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_keys.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_with_keys.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + assert len(notify_entities) == 1 + + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + {"entity_id": notify_entities[0].entity_id, "message": "Hello E2E!"}, + blocking=True, + ) + + mock_send_message.assert_called_once_with(MOCK_RECIPIENT_ID, "Hello E2E!") + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (ThreemaSendError("Send failed"), "Error sending message: Send failed"), + ( + ThreemaConnectionError("Connection error"), + "Error sending message: Connection error", + ), + (ThreemaAuthError("Invalid credentials"), "Invalid authentication"), + ], + ids=["send_error", "connection_error", "auth_error"], +) +async def test_send_message_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_credentials: AsyncMock, + mock_send_message: AsyncMock, + entity_registry: er.EntityRegistry, + side_effect: Exception, + expected_error: str, +) -> None: + """Test notify entity raises HomeAssistantError on send/connection errors.""" + mock_send_message.side_effect = side_effect + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + notify_entities = [e for e in entities if e.domain == NOTIFY_DOMAIN] + + with pytest.raises(HomeAssistantError, match=expected_error): + await hass.services.async_call( + NOTIFY_DOMAIN, + "send_message", + { + "entity_id": notify_entities[0].entity_id, + "message": "Hello!", + }, + blocking=True, + )