mirror of
https://github.com/home-assistant/core.git
synced 2026-09-03 02:34:57 -05:00
2026.4.4 (#169092)
This commit is contained in:
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.4.1"]
|
||||
"requirements": ["aioamazondevices==13.4.3"]
|
||||
}
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260325.7"]
|
||||
"requirements": ["home-assistant-frontend==20260325.8"]
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ DESCRIPTIONS = (
|
||||
key=FlowStatistics.overall.unique_id,
|
||||
translation_key="flow_statistics_overall",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
device_class=SensorDeviceClass.WATER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
char=FlowStatistics.overall,
|
||||
@@ -141,6 +141,7 @@ DESCRIPTIONS = (
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.current.unique_id,
|
||||
translation_key="flow_statistics_current",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
|
||||
@@ -150,7 +151,7 @@ DESCRIPTIONS = (
|
||||
key=FlowStatistics.resettable.unique_id,
|
||||
translation_key="flow_statistics_resettable",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
device_class=SensorDeviceClass.WATER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
char=FlowStatistics.resettable,
|
||||
@@ -166,6 +167,7 @@ DESCRIPTIONS = (
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Spray.current_distance.unique_id,
|
||||
translation_key="spray_current_distance",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
char=Spray.current_distance,
|
||||
|
||||
@@ -49,7 +49,7 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]:
|
||||
integers if found, otherwise None.
|
||||
|
||||
"""
|
||||
if not mime_type.startswith("audio/L"):
|
||||
if not mime_type.lower().startswith("audio/l"):
|
||||
LOGGER.warning("Received unexpected MIME type %s", mime_type)
|
||||
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")
|
||||
|
||||
@@ -65,9 +65,9 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]:
|
||||
with suppress(ValueError, IndexError):
|
||||
rate_str = param.split("=", 1)[1]
|
||||
rate = int(rate_str)
|
||||
elif param.startswith("audio/L"):
|
||||
elif param.lower().startswith("audio/l"):
|
||||
# Keep bits_per_sample as default if conversion fails
|
||||
with suppress(ValueError, IndexError):
|
||||
bits_per_sample = int(param.split("L", 1)[1])
|
||||
bits_per_sample = int(param.upper().split("L", 1)[1])
|
||||
|
||||
return {"bits_per_sample": bits_per_sample, "rate": rate}
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["apyhiveapi"],
|
||||
"requirements": ["pyhive-integration==1.0.8"]
|
||||
"requirements": ["pyhive-integration==1.0.9"]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import timedelta
|
||||
from ipaddress import ip_address
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
@@ -24,16 +23,14 @@ from yarl import URL
|
||||
|
||||
from homeassistant.auth import jwt_wrapper
|
||||
from homeassistant.auth.const import GROUP_ID_READ_ONLY
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.const import HASSIO_USER_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.http import current_request
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
from homeassistant.helpers.network import is_cloud_connection
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util.network import is_local
|
||||
|
||||
from .auth_util import async_user_not_allowed_do_auth
|
||||
from .const import (
|
||||
KEY_AUTHENTICATED,
|
||||
KEY_HASS_REFRESH_TOKEN_ID,
|
||||
@@ -99,38 +96,6 @@ def async_sign_path(
|
||||
return f"{url.path}?{url.query_string}"
|
||||
|
||||
|
||||
@callback
|
||||
def async_user_not_allowed_do_auth(
|
||||
hass: HomeAssistant, user: User, request: Request | None = None
|
||||
) -> str | None:
|
||||
"""Validate that user is not allowed to do auth things."""
|
||||
if not user.is_active:
|
||||
return "User is not active"
|
||||
|
||||
if not user.local_only:
|
||||
return None
|
||||
|
||||
# User is marked as local only, check if they are allowed to do auth
|
||||
if request is None:
|
||||
request = current_request.get()
|
||||
|
||||
if not request:
|
||||
return "No request available to validate local access"
|
||||
|
||||
if is_cloud_connection(hass):
|
||||
return "User is local only"
|
||||
|
||||
try:
|
||||
remote_address = ip_address(request.remote) # type: ignore[arg-type]
|
||||
except ValueError:
|
||||
return "Invalid remote IP"
|
||||
|
||||
if is_local(remote_address):
|
||||
return None
|
||||
|
||||
return "User cannot authenticate remotely"
|
||||
|
||||
|
||||
async def async_setup_auth( # noqa: C901
|
||||
hass: HomeAssistant,
|
||||
app: Application,
|
||||
@@ -217,6 +182,9 @@ async def async_setup_auth( # noqa: C901
|
||||
if refresh_token is None:
|
||||
return False
|
||||
|
||||
if async_user_not_allowed_do_auth(hass, refresh_token.user, request):
|
||||
return False
|
||||
|
||||
request[KEY_HASS_USER] = refresh_token.user
|
||||
request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Auth utilities for the HTTP component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import ip_address
|
||||
|
||||
from aiohttp.web import Request
|
||||
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.http import current_request
|
||||
from homeassistant.helpers.network import is_cloud_connection
|
||||
from homeassistant.util.network import is_local
|
||||
|
||||
|
||||
@callback
|
||||
def async_user_not_allowed_do_auth(
|
||||
hass: HomeAssistant, user: User, request: Request | None = None
|
||||
) -> str | None:
|
||||
"""Validate that user is not allowed to do auth things."""
|
||||
if not user.is_active:
|
||||
return "User is not active"
|
||||
|
||||
if not user.local_only:
|
||||
return None
|
||||
|
||||
# User is marked as local only, check if they are allowed to do auth
|
||||
if request is None:
|
||||
request = current_request.get()
|
||||
|
||||
if not request:
|
||||
return "No request available to validate local access"
|
||||
|
||||
if is_cloud_connection(hass):
|
||||
return "User is local only"
|
||||
|
||||
try:
|
||||
remote_address = ip_address(request.remote) # type: ignore[arg-type]
|
||||
except ValueError:
|
||||
return "Invalid remote IP"
|
||||
|
||||
if is_local(remote_address):
|
||||
return None
|
||||
|
||||
return "User cannot authenticate remotely"
|
||||
@@ -494,6 +494,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||
|
||||
async def _async_wait_push_loop(self) -> None:
|
||||
"""Wait for data push from server."""
|
||||
idle: asyncio.Future | None = None
|
||||
while True:
|
||||
try:
|
||||
self.number_of_messages = await self._async_fetch_number_of_messages()
|
||||
@@ -527,8 +528,9 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||
else:
|
||||
self.auth_errors = 0
|
||||
self.async_set_updated_data(self.number_of_messages)
|
||||
|
||||
try:
|
||||
idle: asyncio.Future = await self.imap_client.idle_start()
|
||||
idle = await self.imap_client.idle_start()
|
||||
await self.imap_client.wait_server_push()
|
||||
self.imap_client.idle_done()
|
||||
async with asyncio.timeout(10):
|
||||
@@ -543,6 +545,24 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator):
|
||||
await self._cleanup()
|
||||
await asyncio.sleep(BACKOFF_TIME)
|
||||
|
||||
finally:
|
||||
# Ensure no pending IDLE future survives
|
||||
if idle is not None and not idle.done():
|
||||
idle.cancel()
|
||||
_LOGGER.debug(
|
||||
"Canceling IDLE wait for %s",
|
||||
self.config_entry.data[CONF_SERVER],
|
||||
)
|
||||
try:
|
||||
await idle
|
||||
except asyncio.CancelledError:
|
||||
if (
|
||||
current_task := asyncio.current_task()
|
||||
) and current_task.cancelling():
|
||||
raise
|
||||
except AioImapException:
|
||||
pass
|
||||
|
||||
async def shutdown(self, *_: Any) -> None:
|
||||
"""Close resources."""
|
||||
if self._push_wait_task:
|
||||
|
||||
@@ -70,7 +70,7 @@ async def build_item_response(media_library, payload, get_thumbnail_url=None):
|
||||
media_content_id=search_id,
|
||||
media_content_type=search_type,
|
||||
title=title,
|
||||
can_play=search_type in PLAYABLE_MEDIA_TYPES and search_id,
|
||||
can_play=bool(search_type in PLAYABLE_MEDIA_TYPES and search_id),
|
||||
can_expand=True,
|
||||
children=children,
|
||||
thumbnail=thumbnail,
|
||||
|
||||
@@ -337,8 +337,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
|
||||
self._attr_brightness = last_attributes.get(
|
||||
ATTR_BRIGHTNESS, self.brightness
|
||||
)
|
||||
self._attr_color_mode = last_attributes.get(
|
||||
ATTR_COLOR_MODE, self.color_mode
|
||||
self._attr_color_mode = (
|
||||
last_attributes.get(ATTR_COLOR_MODE) or self.color_mode
|
||||
)
|
||||
self._attr_color_temp_kelvin = last_attributes.get(
|
||||
ATTR_COLOR_TEMP_KELVIN, self.color_temp_kelvin
|
||||
|
||||
@@ -240,14 +240,16 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_options_failed",
|
||||
)
|
||||
await self.send(
|
||||
RoborockCommand.SET_CUSTOM_MODE,
|
||||
[
|
||||
{v: k for k, v in self._status_trait.fan_speed_mapping.items()}[
|
||||
fan_speed
|
||||
]
|
||||
],
|
||||
)
|
||||
code_mapping = {v: k for k, v in self._status_trait.fan_speed_mapping.items()}
|
||||
if (fan_speed_code := code_mapping.get(fan_speed)) is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_fan_speed",
|
||||
translation_placeholders={
|
||||
"fan_speed": fan_speed,
|
||||
},
|
||||
)
|
||||
await self.send(RoborockCommand.SET_CUSTOM_MODE, [fan_speed_code])
|
||||
|
||||
async def async_set_vacuum_goto_position(self, x: int, y: int) -> None:
|
||||
"""Send vacuum to a specific target point."""
|
||||
@@ -458,9 +460,17 @@ class RoborockQ7Vacuum(RoborockCoordinatedEntityB01Q7, StateVacuumEntity):
|
||||
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
|
||||
"""Set vacuum fan speed."""
|
||||
try:
|
||||
await self.coordinator.api.set_fan_speed(
|
||||
SCWindMapping.from_value(fan_speed)
|
||||
)
|
||||
fan_speed_code = SCWindMapping.from_value(fan_speed)
|
||||
except ValueError as err:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_fan_speed",
|
||||
translation_placeholders={
|
||||
"fan_speed": fan_speed,
|
||||
},
|
||||
) from err
|
||||
try:
|
||||
await self.coordinator.api.set_fan_speed(fan_speed_code)
|
||||
except RoborockException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tibber"],
|
||||
"requirements": ["pyTibber==0.37.1"]
|
||||
"requirements": ["pyTibber==0.37.2"]
|
||||
}
|
||||
|
||||
@@ -102,13 +102,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) ->
|
||||
|
||||
tractive = TractiveClient(hass, client, creds["user_id"], entry)
|
||||
|
||||
trackables = []
|
||||
try:
|
||||
trackable_objects = await client.trackable_objects()
|
||||
trackables = await asyncio.gather(
|
||||
*(_generate_trackables(client, item) for item in trackable_objects)
|
||||
)
|
||||
for obj in await client.trackable_objects():
|
||||
# To avoid hitting Tractive API rate limits, we add a small
|
||||
# delay between requests to fetch trackable details.
|
||||
await asyncio.sleep(2)
|
||||
trackables.append(await _generate_trackables(client, obj))
|
||||
except aiotractive.exceptions.TractiveError as error:
|
||||
await client.close()
|
||||
raise ConfigEntryNotReady from error
|
||||
except ConfigEntryNotReady:
|
||||
await client.close()
|
||||
raise
|
||||
|
||||
# When the pet defined in Tractive has no tracker linked we get None as `trackable`.
|
||||
# So we have to remove None values from trackables list.
|
||||
@@ -164,12 +170,11 @@ async def _generate_trackables(
|
||||
tracker = client.tracker(trackable_data["device_id"])
|
||||
trackable_pet = client.trackable_object(trackable_data["_id"])
|
||||
|
||||
tracker_details, hw_info, pos_report, health_overview = await asyncio.gather(
|
||||
tracker.details(),
|
||||
tracker.hw_info(),
|
||||
tracker.pos_report(),
|
||||
trackable_pet.health_overview(),
|
||||
)
|
||||
# Sequential fetching to prevent HTTP 429 Rate Limits
|
||||
tracker_details = await tracker.details()
|
||||
hw_info = await tracker.hw_info()
|
||||
pos_report = await tracker.pos_report()
|
||||
health_overview = await trackable_pet.health_overview()
|
||||
|
||||
if not tracker_details.get("_id"):
|
||||
raise ConfigEntryNotReady(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiotractive"],
|
||||
"requirements": ["aiotractive==1.0.2"]
|
||||
"requirements": ["aiotractive==1.0.3"]
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from struct import error as struct_error
|
||||
|
||||
from sensor_state_data import SensorUpdate
|
||||
from victron_ble_ha_parser import VictronBluetoothDeviceData
|
||||
from victron_ble_ha_parser.parser import detect_device_type
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothScanningMode,
|
||||
@@ -38,25 +40,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
nonlocal consecutive_failures
|
||||
update = data.update(service_info)
|
||||
|
||||
# Only consider a reauth when the device type is recognised (devices
|
||||
# populated) but the advertisement key fails the quick-check built into
|
||||
# validate_advertisement_key. Using the key check instead of counting
|
||||
# entity values avoids false positives: some devices legitimately return
|
||||
# few (or zero) sensor values when in certain error or alarm states.
|
||||
# Only assess key validity for instant-readout advertisements
|
||||
# (0x10 prefix) whose device type the parser actually recognizes.
|
||||
# Unrecognized mode bytes or non-instant-readout packets are neutral:
|
||||
# they say nothing about whether the encryption key is correct, so
|
||||
# they must not increment or reset the failure counter.
|
||||
raw_data = service_info.manufacturer_data.get(VICTRON_IDENTIFIER)
|
||||
if update.devices and raw_data is not None:
|
||||
if not data.validate_advertisement_key(raw_data):
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= REAUTH_AFTER_FAILURES:
|
||||
_LOGGER.debug(
|
||||
"Triggering reauth for %s after %d consecutive failures",
|
||||
address,
|
||||
consecutive_failures,
|
||||
)
|
||||
entry.async_start_reauth(hass)
|
||||
try:
|
||||
is_recognizable = (
|
||||
raw_data[:1] == b"\x10" and detect_device_type(raw_data) is not None
|
||||
)
|
||||
except struct_error, IndexError:
|
||||
is_recognizable = False
|
||||
|
||||
if is_recognizable:
|
||||
if not data.validate_advertisement_key(raw_data):
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= REAUTH_AFTER_FAILURES:
|
||||
_LOGGER.debug(
|
||||
"Triggering reauth for %s after %d consecutive failures",
|
||||
address,
|
||||
consecutive_failures,
|
||||
)
|
||||
entry.async_start_reauth(hass)
|
||||
consecutive_failures = 0
|
||||
else:
|
||||
consecutive_failures = 0
|
||||
else:
|
||||
consecutive_failures = 0
|
||||
else:
|
||||
consecutive_failures = 0
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from aiohttp.web import Request
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant.components.http.auth_util import async_user_not_allowed_do_auth
|
||||
from homeassistant.components.http.ban import process_success_login, process_wrong_login
|
||||
from homeassistant.components.http.const import KEY_HASS_USER
|
||||
from homeassistant.const import __version__
|
||||
@@ -97,6 +98,13 @@ class AuthPhase:
|
||||
if (access_token := valid_msg.get("access_token")) and (
|
||||
refresh_token := self._hass.auth.async_validate_access_token(access_token)
|
||||
):
|
||||
if user_access_error := async_user_not_allowed_do_auth(
|
||||
self._hass, refresh_token.user, self._request
|
||||
):
|
||||
await self._send_bytes_text(auth_invalid_message(user_access_error))
|
||||
await process_wrong_login(self._request)
|
||||
raise Disconnect
|
||||
|
||||
conn = ActiveConnection(
|
||||
self._logger,
|
||||
self._hass,
|
||||
|
||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2026
|
||||
MINOR_VERSION: Final = 4
|
||||
PATCH_VERSION: Final = "3"
|
||||
PATCH_VERSION: Final = "4"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
|
||||
|
||||
@@ -39,7 +39,7 @@ habluetooth==5.11.1
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20260325.7
|
||||
home-assistant-frontend==20260325.8
|
||||
home-assistant-intents==2026.3.24
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2026.4.3"
|
||||
version = "2026.4.4"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
|
||||
Generated
+5
-5
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.5
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==13.4.1
|
||||
aioamazondevices==13.4.3
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -425,7 +425,7 @@ aiotankerkoenig==0.5.1
|
||||
aiotedee==0.3.0
|
||||
|
||||
# homeassistant.components.tractive
|
||||
aiotractive==1.0.2
|
||||
aiotractive==1.0.3
|
||||
|
||||
# homeassistant.components.unifi
|
||||
aiounifi==88
|
||||
@@ -1229,7 +1229,7 @@ hole==0.9.0
|
||||
holidays==0.94
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260325.7
|
||||
home-assistant-frontend==20260325.8
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.3.24
|
||||
@@ -1925,7 +1925,7 @@ pyRFXtrx==0.31.1
|
||||
pySDCP==1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.37.1
|
||||
pyTibber==0.37.2
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.8.0
|
||||
@@ -2155,7 +2155,7 @@ pyhaversion==22.8.0
|
||||
pyheos==1.0.6
|
||||
|
||||
# homeassistant.components.hive
|
||||
pyhive-integration==1.0.8
|
||||
pyhive-integration==1.0.9
|
||||
|
||||
# homeassistant.components.homematic
|
||||
pyhomematic==0.1.77
|
||||
|
||||
Generated
+5
-5
@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.5
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==13.4.1
|
||||
aioamazondevices==13.4.3
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -410,7 +410,7 @@ aiotankerkoenig==0.5.1
|
||||
aiotedee==0.3.0
|
||||
|
||||
# homeassistant.components.tractive
|
||||
aiotractive==1.0.2
|
||||
aiotractive==1.0.3
|
||||
|
||||
# homeassistant.components.unifi
|
||||
aiounifi==88
|
||||
@@ -1093,7 +1093,7 @@ hole==0.9.0
|
||||
holidays==0.94
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260325.7
|
||||
home-assistant-frontend==20260325.8
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.3.24
|
||||
@@ -1668,7 +1668,7 @@ pyHomee==1.3.8
|
||||
pyRFXtrx==0.31.1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.37.1
|
||||
pyTibber==0.37.2
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.8.0
|
||||
@@ -1847,7 +1847,7 @@ pyhaversion==22.8.0
|
||||
pyheos==1.0.6
|
||||
|
||||
# homeassistant.components.hive
|
||||
pyhive-integration==1.0.8
|
||||
pyhive-integration==1.0.9
|
||||
|
||||
# homeassistant.components.homematic
|
||||
pyhomematic==0.1.77
|
||||
|
||||
@@ -92,7 +92,9 @@
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
@@ -127,6 +129,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Mock Title Current distance',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -143,7 +146,9 @@
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
@@ -182,6 +187,7 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'volume_flow_rate',
|
||||
'friendly_name': 'Mock Title Current flow',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.LITERS_PER_MINUTE: 'L/min'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -399,7 +405,7 @@
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>,
|
||||
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Overall flow',
|
||||
'platform': 'gardena_bluetooth',
|
||||
@@ -414,7 +420,7 @@
|
||||
# name: test_sensors[aqua_contour][sensor.mock_title_overall_flow-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'volume',
|
||||
'device_class': 'water',
|
||||
'friendly_name': 'Mock Title Overall flow',
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Tests for the Google Generative AI Conversation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.google_generative_ai_conversation.helpers import (
|
||||
_parse_audio_mime_type,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
def test_parse_audio_mime_type_uppercase() -> None:
|
||||
"""Test parsing uppercase MIME type audio/L16;rate=24000."""
|
||||
result = _parse_audio_mime_type("audio/L16;rate=24000")
|
||||
assert result == {"bits_per_sample": 16, "rate": 24000}
|
||||
|
||||
|
||||
def test_parse_audio_mime_type_lowercase() -> None:
|
||||
"""Test parsing lowercase MIME type audio/l16; rate=24000; channels=1."""
|
||||
result = _parse_audio_mime_type("audio/l16; rate=24000; channels=1")
|
||||
assert result == {"bits_per_sample": 16, "rate": 24000}
|
||||
|
||||
|
||||
def test_parse_audio_mime_type_unsupported_raises() -> None:
|
||||
"""Test that an unsupported MIME type raises HomeAssistantError."""
|
||||
with pytest.raises(HomeAssistantError):
|
||||
_parse_audio_mime_type("video/mp4")
|
||||
@@ -592,6 +592,91 @@ async def test_local_only_user_rejected(
|
||||
assert req.status == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
|
||||
async def test_auth_access_signed_path_with_local_only_user(
|
||||
hass: HomeAssistant,
|
||||
app: web.Application,
|
||||
aiohttp_client: ClientSessionGenerator,
|
||||
hass_access_token: str,
|
||||
) -> None:
|
||||
"""Test access with signed url for a local-only user."""
|
||||
app.router.add_post("/", mock_handler)
|
||||
app.router.add_get("/another_path", mock_handler)
|
||||
await async_setup_auth(hass, app)
|
||||
set_mock_ip = mock_real_ip(app)
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
|
||||
refresh_token.user.local_only = True
|
||||
|
||||
signed_path = async_sign_path(
|
||||
hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id
|
||||
)
|
||||
|
||||
# Local IP is allowed for local-only user
|
||||
set_mock_ip("192.168.1.123")
|
||||
|
||||
req = await client.head(signed_path)
|
||||
assert req.status == HTTPStatus.OK
|
||||
|
||||
req = await client.get(signed_path)
|
||||
assert req.status == HTTPStatus.OK
|
||||
data = await req.json()
|
||||
assert data["user_id"] == refresh_token.user.id
|
||||
|
||||
# Remote IP is rejected for local-only user
|
||||
for remote_addr in EXTERNAL_ADDRESSES:
|
||||
set_mock_ip(remote_addr)
|
||||
signed_path = async_sign_path(
|
||||
hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id
|
||||
)
|
||||
|
||||
req = await client.head(signed_path)
|
||||
assert req.status == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
req = await client.get(signed_path)
|
||||
assert req.status == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
|
||||
async def test_auth_access_signed_path_with_inactive_user(
|
||||
hass: HomeAssistant,
|
||||
app: web.Application,
|
||||
aiohttp_client: ClientSessionGenerator,
|
||||
hass_access_token: str,
|
||||
) -> None:
|
||||
"""Test access with signed url for an inactive user."""
|
||||
app.router.add_post("/", mock_handler)
|
||||
app.router.add_get("/another_path", mock_handler)
|
||||
await async_setup_auth(hass, app)
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
|
||||
|
||||
signed_path = async_sign_path(
|
||||
hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id
|
||||
)
|
||||
|
||||
# Active user is allowed
|
||||
req = await client.head(signed_path)
|
||||
assert req.status == HTTPStatus.OK
|
||||
|
||||
req = await client.get(signed_path)
|
||||
assert req.status == HTTPStatus.OK
|
||||
data = await req.json()
|
||||
assert data["user_id"] == refresh_token.user.id
|
||||
signed_path = async_sign_path(
|
||||
hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id
|
||||
)
|
||||
|
||||
# Inactive user is rejected
|
||||
refresh_token.user.is_active = False
|
||||
|
||||
req = await client.head(signed_path)
|
||||
assert req.status == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
req = await client.get(signed_path)
|
||||
assert req.status == HTTPStatus.UNAUTHORIZED
|
||||
|
||||
|
||||
async def test_async_user_not_allowed_do_auth(
|
||||
hass: HomeAssistant, app: web.Application
|
||||
) -> None:
|
||||
|
||||
@@ -538,6 +538,7 @@ async def test_lost_connection_with_imap_push(
|
||||
) -> None:
|
||||
"""Test error handling when the connection is lost."""
|
||||
# Mock an error in waiting for a pushed update
|
||||
mock_imap_protocol.idle_start.return_value = asyncio.Future()
|
||||
mock_imap_protocol.wait_server_push.side_effect = imap_wait_server_push_exception
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
|
||||
config_entry.add_to_hass(hass)
|
||||
@@ -550,6 +551,10 @@ async def test_lost_connection_with_imap_push(
|
||||
assert state is not None
|
||||
assert state.state == "0"
|
||||
|
||||
async_fire_time_changed(hass, utcnow() + timedelta(seconds=30))
|
||||
await hass.async_block_till_done()
|
||||
assert "Canceling IDLE wait for imap.server.com" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("imap_has_capability", [True], ids=["push"])
|
||||
async def test_fetch_number_of_messages(
|
||||
|
||||
@@ -565,6 +565,63 @@ async def test_single_color_mode_turn_on(
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
{
|
||||
mqtt.DOMAIN: {
|
||||
light.DOMAIN: {
|
||||
"schema": "json",
|
||||
"name": "test",
|
||||
"command_topic": "test_light/set",
|
||||
"supported_color_modes": ["brightness"],
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
mqtt.DOMAIN: {
|
||||
light.DOMAIN: {
|
||||
"schema": "json",
|
||||
"name": "test",
|
||||
"command_topic": "test_light/set",
|
||||
"supported_color_modes": ["color_temp"],
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_restore_state_with_none_color_mode(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
) -> None:
|
||||
"""Test restoring state with a None color_mode does not break turn_on.
|
||||
|
||||
Regression test: When an optimistic light was off at the time the state
|
||||
was last saved, `state_attributes` stores `color_mode: None`. On restart,
|
||||
the restore path would overwrite the correctly initialized color_mode with
|
||||
None, causing turn_on to raise "does not report a color mode".
|
||||
"""
|
||||
fake_state = State(
|
||||
"light.test",
|
||||
STATE_OFF,
|
||||
{"color_mode": None, "brightness": None},
|
||||
)
|
||||
mock_restore_cache(hass, (fake_state,))
|
||||
|
||||
mqtt_mock = await mqtt_mock_entry()
|
||||
|
||||
state = hass.states.get("light.test")
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
# This should not raise "does not report a color mode"
|
||||
await common.async_turn_on(hass, "light.test")
|
||||
mqtt_mock.async_publish.assert_called_once_with(
|
||||
"test_light/set", '{"state":"ON"}', 0, False
|
||||
)
|
||||
state = hass.states.get("light.test")
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes.get("color_mode") is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
|
||||
@@ -142,6 +142,29 @@ async def test_commands(
|
||||
assert vacuum_command.send.call_args == call(command, params=called_params)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entity_id",
|
||||
[
|
||||
ENTITY_ID,
|
||||
Q7_ENTITY_ID,
|
||||
Q10_ENTITY_ID,
|
||||
],
|
||||
)
|
||||
async def test_set_fan_speed_invalid(
|
||||
hass: HomeAssistant,
|
||||
setup_entry: MockConfigEntry,
|
||||
entity_id: str,
|
||||
) -> None:
|
||||
"""Test calling set_fan_speed with an invalid mode."""
|
||||
with pytest.raises(ServiceValidationError, match="Invalid fan speed: some-mode"):
|
||||
await hass.services.async_call(
|
||||
VACUUM_DOMAIN,
|
||||
SERVICE_SET_FAN_SPEED,
|
||||
{ATTR_ENTITY_ID: entity_id, "fan_speed": "some-mode"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("in_cleaning_int", "in_returning_int", "expected_command"),
|
||||
[
|
||||
@@ -880,25 +903,6 @@ async def test_q10_set_fan_speed_command(
|
||||
assert q10_vacuum_api.vacuum.set_fan_level.call_args[0] == (YXFanLevel.QUIET,)
|
||||
|
||||
|
||||
async def test_q10_set_invalid_fan_speed(
|
||||
hass: HomeAssistant,
|
||||
setup_entry: MockConfigEntry,
|
||||
q10_vacuum_api: Mock,
|
||||
) -> None:
|
||||
"""Test that setting an invalid fan speed raises an error."""
|
||||
vacuum = hass.states.get(Q10_ENTITY_ID)
|
||||
assert vacuum
|
||||
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await hass.services.async_call(
|
||||
VACUUM_DOMAIN,
|
||||
SERVICE_SET_FAN_SPEED,
|
||||
{ATTR_ENTITY_ID: Q10_ENTITY_ID, "fan_speed": "invalid_speed"},
|
||||
blocking=True,
|
||||
)
|
||||
assert q10_vacuum_api.vacuum.set_fan_level.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
|
||||
@@ -89,6 +89,10 @@ def mock_tractive_client() -> Generator[AsyncMock]:
|
||||
patch(
|
||||
"homeassistant.components.tractive.aiotractive.Tractive", autospec=True
|
||||
) as mock_client,
|
||||
patch(
|
||||
"homeassistant.components.tractive.asyncio.sleep",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
client = mock_client.return_value
|
||||
client.authenticate.return_value = {"user_id": "12345"}
|
||||
|
||||
@@ -201,6 +201,22 @@ VICTRON_DC_DC_CONVERTER_UNKNOWN_OFF_REASON_SERVICE_INFO = BluetoothServiceInfo(
|
||||
source="local",
|
||||
)
|
||||
|
||||
# Same Victron manufacturer data prefix but with an unrecognized mode byte
|
||||
# (0xEE at offset 4). detect_device_type returns None for this payload,
|
||||
# so validate_advertisement_key would also return False. The reauth logic
|
||||
# must treat this as neutral (not a key failure).
|
||||
VICTRON_VEBUS_UNRECOGNIZED_MODE_SERVICE_INFO = BluetoothServiceInfo(
|
||||
name="Inverter Charger",
|
||||
address="01:02:03:04:05:06",
|
||||
rssi=-60,
|
||||
manufacturer_data={
|
||||
0x02E1: bytes.fromhex("10038027ee1252dad26f0b8eb39162074d140df410")
|
||||
},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
source="local",
|
||||
)
|
||||
|
||||
VICTRON_VEBUS_SENSORS = {
|
||||
"inverter_charger_device_state": "float",
|
||||
"inverter_charger_battery_voltage": "14.45",
|
||||
|
||||
@@ -41,6 +41,7 @@ from .fixtures import (
|
||||
VICTRON_VEBUS_BAD_KEY_SERVICE_INFO,
|
||||
VICTRON_VEBUS_SERVICE_INFO,
|
||||
VICTRON_VEBUS_TOKEN,
|
||||
VICTRON_VEBUS_UNRECOGNIZED_MODE_SERVICE_INFO,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
@@ -165,6 +166,28 @@ def _inject_bad_advertisement(hass: HomeAssistant, seq: int = 0) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _inject_unrecognized_mode_advertisement(hass: HomeAssistant, seq: int = 0) -> None:
|
||||
"""Inject a Victron advertisement with an unrecognized mode byte.
|
||||
|
||||
detect_device_type returns None for this payload so the reauth guard
|
||||
must treat it as neutral (neither increment nor reset the failure counter).
|
||||
"""
|
||||
info = VICTRON_VEBUS_UNRECOGNIZED_MODE_SERVICE_INFO
|
||||
raw = bytearray(info.manufacturer_data[VICTRON_IDENTIFIER])
|
||||
raw[-1] = seq & 0xFF
|
||||
device = generate_ble_device(address=info.address, name=info.name, details={})
|
||||
adv = generate_advertisement_data(
|
||||
local_name=info.name,
|
||||
manufacturer_data={VICTRON_IDENTIFIER: bytes(raw)},
|
||||
service_data=info.service_data,
|
||||
service_uuids=info.service_uuids,
|
||||
rssi=-60,
|
||||
)
|
||||
inject_advertisement_with_time_and_source_connectable(
|
||||
hass, device, adv, time.monotonic(), "local", True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_bluetooth")
|
||||
async def test_reauth_triggered_after_consecutive_failures(
|
||||
hass: HomeAssistant,
|
||||
@@ -323,3 +346,76 @@ async def test_charger_error_state(
|
||||
state = hass.states.get("sensor.solar_charger_charger_error")
|
||||
assert state is not None
|
||||
assert state.state == expected_state
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_bluetooth")
|
||||
async def test_reauth_not_triggered_on_unrecognized_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_added_to_hass: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reauth is NOT triggered by advertisements with unrecognized mode bytes.
|
||||
|
||||
Some Victron devices broadcast advertisements with mode bytes that
|
||||
detect_device_type does not recognize (returns None).
|
||||
validate_advertisement_key also returns False for these, but that does
|
||||
not mean the encryption key is wrong.
|
||||
|
||||
Regression test for https://github.com/home-assistant/core/issues/168019
|
||||
"""
|
||||
entry = mock_config_entry_added_to_hass
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# First inject a valid advertisement so update.devices is populated
|
||||
inject_bluetooth_service_info(hass, VICTRON_VEBUS_SERVICE_INFO)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Now send many unrecognized-mode advertisements
|
||||
for i in range(REAUTH_AFTER_FAILURES + 5):
|
||||
_inject_unrecognized_mode_advertisement(hass, seq=i)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
|
||||
assert len(flows) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_bluetooth")
|
||||
async def test_reauth_still_triggers_across_unrecognized_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_added_to_hass: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that unrecognized-mode advertisements are neutral for the failure counter.
|
||||
|
||||
The sequence bad → bad → unrecognized → bad must still trigger reauth
|
||||
because unrecognized advertisements should neither increment nor reset the
|
||||
consecutive failure counter.
|
||||
|
||||
Regression test for https://github.com/home-assistant/core/issues/168019
|
||||
"""
|
||||
entry = mock_config_entry_added_to_hass
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# First inject a valid advertisement so update.devices is populated
|
||||
inject_bluetooth_service_info(hass, VICTRON_VEBUS_SERVICE_INFO)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# bad, bad (2 failures)
|
||||
_inject_bad_advertisement(hass, seq=100)
|
||||
await hass.async_block_till_done()
|
||||
_inject_bad_advertisement(hass, seq=101)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# unrecognized mode — should be neutral
|
||||
_inject_unrecognized_mode_advertisement(hass, seq=50)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# one more bad → 3 consecutive failures → reauth
|
||||
_inject_bad_advertisement(hass, seq=102)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN)
|
||||
assert len(flows) == 1
|
||||
assert flows[0]["context"]["source"] == "reauth"
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.test_util import mock_real_ip
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
@@ -151,6 +152,66 @@ async def test_auth_active_user_inactive(
|
||||
assert auth_msg["type"] == TYPE_AUTH_INVALID
|
||||
|
||||
|
||||
async def test_auth_local_only_user_rejected_remote(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
hass_access_token: str,
|
||||
) -> None:
|
||||
"""Test that a local-only user cannot authenticate from a remote IP."""
|
||||
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
|
||||
refresh_token.user.local_only = True
|
||||
|
||||
assert await async_setup_component(hass, "websocket_api", {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
set_mock_ip = mock_real_ip(hass.http.app)
|
||||
set_mock_ip("198.51.100.1")
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.websocket_api.auth.process_wrong_login",
|
||||
) as mock_process_wrong_login:
|
||||
async with client.ws_connect(URL) as ws:
|
||||
auth_msg = await ws.receive_json()
|
||||
assert auth_msg["type"] == TYPE_AUTH_REQUIRED
|
||||
|
||||
await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token})
|
||||
|
||||
auth_msg = await ws.receive_json()
|
||||
assert auth_msg["type"] == TYPE_AUTH_INVALID
|
||||
assert auth_msg["message"] == "User cannot authenticate remotely"
|
||||
|
||||
assert mock_process_wrong_login.called
|
||||
|
||||
|
||||
async def test_auth_local_only_user_allowed_local(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
hass_access_token: str,
|
||||
) -> None:
|
||||
"""Test that a local-only user can authenticate from a local IP."""
|
||||
refresh_token = hass.auth.async_validate_access_token(hass_access_token)
|
||||
refresh_token.user.local_only = True
|
||||
|
||||
assert await async_setup_component(hass, "websocket_api", {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
set_mock_ip = mock_real_ip(hass.http.app)
|
||||
set_mock_ip("192.168.1.100")
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
|
||||
async with client.ws_connect(URL) as ws:
|
||||
auth_msg = await ws.receive_json()
|
||||
assert auth_msg["type"] == TYPE_AUTH_REQUIRED
|
||||
|
||||
await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token})
|
||||
|
||||
auth_msg = await ws.receive_json()
|
||||
assert auth_msg["type"] == TYPE_AUTH_OK
|
||||
|
||||
|
||||
async def test_auth_active_with_password_not_allow(
|
||||
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user