1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-04-02 08:12:47 +01:00
Files
supervisor/tests/homeassistant/test_module.py
Stefan Agner 3147d080a2 Unify Core user handling with HomeAssistantUser model (#6558)
* Unify Core user listing with HomeAssistantUser model

Replace the ingress-specific IngressSessionDataUser with a general
HomeAssistantUser dataclass that models the Core config/auth/list WS
response. This deduplicates the WS call (previously in both auth.py
and module.py) into a single HomeAssistant.list_users() method.

- Add HomeAssistantUser dataclass with fields matching Core's user API
- Remove get_users() and its unnecessary 5-minute Job throttle
- Auth and ingress consumers both use HomeAssistant.list_users()
- Auth API endpoint uses typed attribute access instead of dict keys
- Migrate session serialization from legacy "displayname" to "name"
- Accept both keys in schema/deserialization for backwards compat
- Add test for loading persisted sessions with legacy displayname key

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Tighten list_users() to trust Core's auth/list contract

Core's config/auth/list WS command always returns a list, never None.
Replace the silent `if not raw: return []` (which also swallowed empty
lists) with an assert, remove the dead AuthListUsersNoneResponseError
exception class, and document the HomeAssistantWSError contract in the
docstring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove | None from async_send_command return type

The WebSocket result is always set from data["result"] in _receive_json,
never explicitly to None. Remove the misleading | None from the return
type of both WSClient and HomeAssistantWebSocket async_send_command, and
drop the now-unnecessary assert in list_users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use HomeAssistantWSConnectionError in _ensure_connected

_ensure_connected and connect_with_auth raise on connection-level
failures, so use the more specific HomeAssistantWSConnectionError
instead of the broad HomeAssistantWSError. This allows callers to
distinguish connection errors from Core API errors (e.g. unsuccessful
WebSocket command responses). Also document that _ensure_connected can
propagate HomeAssistantAuthError from ensure_access_token.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove user list cache from _find_user_by_id

Drop the _list_of_users cache to avoid stale auth data in ingress
session creation. The method now fetches users fresh each time and
returns None on any API error instead of serving potentially outdated
cached results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:31:08 +01:00

187 lines
6.8 KiB
Python

"""Test Homeassistant module."""
import asyncio
import errno
import logging
from pathlib import Path, PurePath
from unittest.mock import AsyncMock, patch
import pytest
from supervisor.backups.backup import Backup
from supervisor.backups.const import BackupType
from supervisor.const import CoreState
from supervisor.coresys import CoreSys
from supervisor.docker.interface import DockerInterface
from supervisor.exceptions import (
HomeAssistantBackupError,
HomeAssistantWSConnectionError,
)
from supervisor.homeassistant.module import HomeAssistant
from supervisor.homeassistant.secrets import HomeAssistantSecrets
from supervisor.homeassistant.websocket import HomeAssistantWebSocket
from supervisor.utils.dt import utcnow
async def test_load(
coresys: CoreSys, tmp_supervisor_data: Path, ha_ws_client: AsyncMock
):
"""Test homeassistant module load."""
with open(
tmp_supervisor_data / "homeassistant" / "secrets.yaml", "w", encoding="utf-8"
) as secrets:
secrets.write("hello: world\n")
# Unwrap read_secrets to prevent throttling between tests
with (
patch.object(DockerInterface, "attach") as attach,
patch.object(DockerInterface, "check_image") as check_image,
patch.object(
HomeAssistantSecrets,
"_read_secrets",
new=HomeAssistantSecrets._read_secrets.__wrapped__, # pylint: disable=protected-access,no-member
),
):
await coresys.homeassistant.load()
attach.assert_called_once()
check_image.assert_called_once()
assert coresys.homeassistant.secrets.secrets == {"hello": "world"}
await coresys.core.set_state(CoreState.SETUP)
await coresys.homeassistant.websocket._async_send_command({"lorem": "ipsum"})
ha_ws_client.async_send_command.assert_not_called()
await coresys.core.set_state(CoreState.RUNNING)
await asyncio.sleep(0)
assert ha_ws_client.async_send_command.call_args_list[0][0][0] == {"lorem": "ipsum"}
async def test_list_users_none(coresys: CoreSys, ha_ws_client: AsyncMock):
"""Test list users raises on unexpected None response from Core."""
ha_ws_client.async_send_command.return_value = None
with pytest.raises(TypeError):
await coresys.homeassistant.list_users()
async def test_write_pulse_error(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
"""Test errors writing pulse config."""
with patch(
"supervisor.homeassistant.module.Path.write_text",
side_effect=(err := OSError()),
):
err.errno = errno.EBUSY
await coresys.homeassistant.write_pulse()
assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is True
caplog.clear()
err.errno = errno.EBADMSG
await coresys.homeassistant.write_pulse()
assert "can't write pulse/client.config" in caplog.text
assert coresys.core.healthy is False
async def test_begin_backup_ws_error(coresys: CoreSys):
"""Test WS error when beginning backup."""
# pylint: disable-next=protected-access
coresys.homeassistant.websocket._client.async_send_command.side_effect = (
HomeAssistantWSConnectionError("Connection was closed")
)
with (
patch.object(HomeAssistantWebSocket, "_ensure_connected", return_value=None),
pytest.raises(
HomeAssistantBackupError,
match="Preparing backup of Home Assistant Core failed. Failed to inform HA Core: Connection was closed.",
),
):
await coresys.homeassistant.begin_backup()
async def test_end_backup_ws_error(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
"""Test WS error when ending backup."""
# pylint: disable-next=protected-access
coresys.homeassistant.websocket._client.async_send_command.side_effect = (
HomeAssistantWSConnectionError("Connection was closed")
)
with patch.object(HomeAssistantWebSocket, "_ensure_connected", return_value=None):
await coresys.homeassistant.end_backup()
assert (
"Error resuming normal operations after backup of Home Assistant Core. Failed to inform HA Core: Connection was closed."
in caplog.text
)
@pytest.mark.parametrize(
("filename", "exclude_db", "expect_excluded", "subfolder"),
[
("home-assistant.log", False, True, None),
("home-assistant.log.1", False, True, None),
("home-assistant.log.fault", False, True, None),
("home-assistant.log", False, False, "subfolder"),
("OZW_Log.txt", False, True, None),
("OZW_Log.txt", False, False, "subfolder"),
("home-assistant_v2.db-shm", False, True, None),
("home-assistant_v2.db-shm", False, False, "subfolder"),
("home-assistant_v2.db", False, False, None),
("home-assistant_v2.db", True, True, None),
("home-assistant_v2.db", True, False, "subfolder"),
("home-assistant_v2.db-wal", False, False, None),
("home-assistant_v2.db-wal", True, True, None),
("home-assistant_v2.db-wal", True, False, "subfolder"),
("test.tar", False, True, "backups"),
("test.tar", False, False, "subfolder/backups"),
("test.tar", False, True, "tmp_backups"),
("test.tar", False, False, "subfolder/tmp_backups"),
("test", False, True, "tts"),
("test", False, False, "subfolder/tts"),
("test.cpython-312.pyc", False, True, "__pycache__"),
("test.cpython-312.pyc", False, True, "subfolder/__pycache__"),
(".DS_Store", False, True, None),
(".DS_Store", False, True, "subfolder"),
(
"core.restore_state.corrupt.2025-03-26T20:55:45.635297+00:00",
False,
True,
".storage",
),
],
)
@pytest.mark.usefixtures("tmp_supervisor_data")
async def test_backup_excludes(
coresys: CoreSys,
caplog: pytest.LogCaptureFixture,
filename: str,
exclude_db: bool,
expect_excluded: bool,
subfolder: str | None,
):
"""Test excludes in backup."""
parent = coresys.config.path_homeassistant
if subfolder:
test_path = PurePath(subfolder, filename)
parent = coresys.config.path_homeassistant / subfolder
parent.mkdir(parents=True)
else:
test_path = PurePath(filename)
(parent / filename).touch()
backup = Backup(coresys, coresys.config.path_backup / "test.tar", "test", None)
backup.new("test", utcnow().isoformat(), BackupType.PARTIAL)
async with backup.create():
with (
patch.object(HomeAssistant, "begin_backup"),
patch.object(HomeAssistant, "end_backup"),
caplog.at_level(logging.DEBUG, logger="supervisor.homeassistant.module"),
):
await backup.store_homeassistant(exclude_database=exclude_db)
assert (
f"Ignoring data/{test_path.as_posix()} because of " in caplog.text
) is expect_excluded