mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-07-06 21:35:10 +01:00
ed91b18c4b
* tests: enable flake8-pytest-style (PT) ruff rules Enable the `PT` ruff rule set and fix the resulting violations across the test suite: - PT006: pass parametrize argument names as tuples instead of a single comma-separated string. - PT022: switch fixtures that have no teardown from `yield` to `return` so the lack of cleanup is obvious at a glance. - PT011: add `match=` to broad `pytest.raises(ValueError)` blocks so the expected error is anchored to a specific message. - PT012: hoist setup (patches, branching) out of `pytest.raises()` blocks so only the call that is expected to raise remains inside. - PT013: replace `from pytest import X` with `import pytest` and access attributes via the module. - PT015: replace `try/except` + `assert False` patterns with `pytest.raises(...)`. - PT017: replace `assert` on exceptions inside `except` blocks with `pytest.raises(...) as exc_info` and assert on `exc_info.value`. No behavioral changes to the tests; the full suite still passes. * tests: address review feedback on PT ruff rule enablement - Fix fixture return-type annotations after switching `yield` to `return` in tests/conftest.py: drop the `Generator[...]`/`AsyncGenerator[...]` wrapper for `dns_manager_service`, `supervisor_internet`, `websession`, and `mock_update_data` so the annotation matches what the fixture actually returns. - Correct the return-type annotation of `fixture_ip6config_service` from `IP4ConfigService` to `IP6ConfigService`. - Fix recurring "excepiton" typo in tests/utils/test_exception_helper.py. * tests: verify backup cleanup on permission error After `test_new_backup_permission_error` raises `BackupPermissionError`, assert that no tarfile was left behind and `tmp_path` is empty. The previous version only checked that the exception was raised, which missed any regression where a partial tarfile would survive the failed create. * tests: rename DNS_GOOD_V6 to DNS_V6_UNSUPPORTED The constant was named "good" but its tests assert that the URLs are rejected by the DNS validator. The IPv6 URLs are well-formed but currently rejected because IPv6 doesn't work with the Docker network (see `dns_url` in supervisor/validate.py). Rename the constant and the related test to make the intent obvious.
264 lines
9.2 KiB
Python
264 lines
9.2 KiB
Python
"""Test host logs control."""
|
|
|
|
import asyncio
|
|
from unittest.mock import MagicMock, PropertyMock, patch
|
|
|
|
from aiohttp.client_exceptions import UnixClientConnectorError
|
|
from aiohttp.client_reqrep import ConnectionKey
|
|
import pytest
|
|
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.exceptions import HostNotSupportedError, HostServiceError
|
|
from supervisor.host.const import LogFormatter
|
|
from supervisor.host.logs import LogsControl
|
|
from supervisor.utils.systemd_journal import journal_logs_reader
|
|
|
|
from tests.common import load_fixture
|
|
|
|
TEST_BOOT_IDS = [
|
|
"b2aca10d5ca54fb1b6fb35c85a0efca9",
|
|
"b1c386a144fd44db8f855d7e907256f8",
|
|
]
|
|
|
|
TEST_BOOTS_IDS_NATIVE = [
|
|
"77f66b1fd6b2416e8eebf509c9a470e6",
|
|
"2411cf84b38a41939de746afc7a22e18",
|
|
"3fa78d16a4ee4975b632d24dea52404c",
|
|
"b81a432efb2d4a71a1e2d1b42892c187",
|
|
"aeaa3d67efe9410ba7210cbee21bb022",
|
|
]
|
|
|
|
|
|
async def test_load(coresys: CoreSys):
|
|
"""Test load."""
|
|
assert coresys.host.logs.default_identifiers == []
|
|
|
|
await coresys.host.logs.load()
|
|
assert coresys.host.logs.boot_ids == []
|
|
|
|
# File is quite large so just check it loaded
|
|
for identifier in ["kernel", "os-agent", "systemd"]:
|
|
assert identifier in coresys.host.logs.default_identifiers
|
|
|
|
|
|
async def test_logs(journald_gateway: MagicMock, coresys: CoreSys):
|
|
"""Test getting logs and errors."""
|
|
assert coresys.host.logs.available is True
|
|
|
|
journald_gateway.content.feed_data(
|
|
load_fixture("logs_export_host.txt").encode("utf-8")
|
|
)
|
|
journald_gateway.content.feed_eof()
|
|
|
|
async with coresys.host.logs.journald_logs() as resp:
|
|
cursor, line = await anext(
|
|
journal_logs_reader(resp, log_formatter=LogFormatter.VERBOSE)
|
|
)
|
|
assert (
|
|
cursor
|
|
== "s=83fee99ca0c3466db5fc120d52ca7dd8;i=203f2ce;b=f5a5c442fa6548cf97474d2d57c920b3;m=3191a3c620;t=612ccd299e7af;x=8675b540119d10bb"
|
|
)
|
|
assert (
|
|
line
|
|
== "2024-03-04 02:52:56.193 homeassistant systemd[1]: Started Hostname Service."
|
|
)
|
|
|
|
with (
|
|
patch.object(LogsControl, "available", new=PropertyMock(return_value=False)),
|
|
pytest.raises(HostNotSupportedError),
|
|
):
|
|
async with coresys.host.logs.journald_logs():
|
|
pass
|
|
|
|
|
|
async def test_logs_coloured(journald_gateway: MagicMock, coresys: CoreSys):
|
|
"""Test ANSI control sequences being preserved in binary messages."""
|
|
journald_gateway.content.feed_data(
|
|
load_fixture("logs_export_supervisor.txt").encode("utf-8")
|
|
)
|
|
journald_gateway.content.feed_eof()
|
|
|
|
async with coresys.host.logs.journald_logs() as resp:
|
|
cursor, line = await anext(journal_logs_reader(resp))
|
|
assert (
|
|
cursor
|
|
== "s=83fee99ca0c3466db5fc120d52ca7dd8;i=2049389;b=f5a5c442fa6548cf97474d2d57c920b3;m=4263828e8c;t=612dda478b01b;x=9ae12394c9326930"
|
|
)
|
|
assert (
|
|
line
|
|
== "\x1b[32m24-03-04 23:56:56 INFO (MainThread) [__main__] Closing Supervisor\x1b[0m"
|
|
)
|
|
|
|
|
|
async def test_logs_no_colors(journald_gateway: MagicMock, coresys: CoreSys):
|
|
"""Test ANSI color codes being stripped when no_colors=True."""
|
|
journald_gateway.content.feed_data(
|
|
load_fixture("logs_export_supervisor.txt").encode("utf-8")
|
|
)
|
|
journald_gateway.content.feed_eof()
|
|
|
|
async with coresys.host.logs.journald_logs() as resp:
|
|
cursor, line = await anext(journal_logs_reader(resp, no_colors=True))
|
|
assert (
|
|
cursor
|
|
== "s=83fee99ca0c3466db5fc120d52ca7dd8;i=2049389;b=f5a5c442fa6548cf97474d2d57c920b3;m=4263828e8c;t=612dda478b01b;x=9ae12394c9326930"
|
|
)
|
|
# Colors should be stripped
|
|
assert (
|
|
line == "24-03-04 23:56:56 INFO (MainThread) [__main__] Closing Supervisor"
|
|
)
|
|
|
|
|
|
async def test_logs_verbose_no_colors(journald_gateway: MagicMock, coresys: CoreSys):
|
|
"""Test ANSI color codes being stripped from verbose formatted logs when no_colors=True."""
|
|
journald_gateway.content.feed_data(
|
|
load_fixture("logs_export_supervisor.txt").encode("utf-8")
|
|
)
|
|
journald_gateway.content.feed_eof()
|
|
|
|
async with coresys.host.logs.journald_logs() as resp:
|
|
cursor, line = await anext(
|
|
journal_logs_reader(
|
|
resp, log_formatter=LogFormatter.VERBOSE, no_colors=True
|
|
)
|
|
)
|
|
assert (
|
|
cursor
|
|
== "s=83fee99ca0c3466db5fc120d52ca7dd8;i=2049389;b=f5a5c442fa6548cf97474d2d57c920b3;m=4263828e8c;t=612dda478b01b;x=9ae12394c9326930"
|
|
)
|
|
# Colors should be stripped in verbose format too
|
|
assert (
|
|
line
|
|
== "2024-03-04 22:56:56.709 ha-hloub hassio_supervisor[466]: 24-03-04 23:56:56 INFO (MainThread) [__main__] Closing Supervisor"
|
|
)
|
|
|
|
|
|
async def test_boot_ids(
|
|
journald_gateway: MagicMock,
|
|
coresys: CoreSys,
|
|
without_journal_gatewayd_boots: MagicMock,
|
|
):
|
|
"""Test getting boot ids."""
|
|
journald_gateway.content.feed_data(
|
|
load_fixture("logs_boot_ids.txt").encode("utf-8")
|
|
)
|
|
journald_gateway.content.feed_eof()
|
|
|
|
assert await coresys.host.logs.get_boot_ids() == TEST_BOOT_IDS
|
|
|
|
# Boot ID query should not be run again, mock a failure for it to ensure
|
|
journald_gateway.get.side_effect = TimeoutError()
|
|
assert await coresys.host.logs.get_boot_ids() == TEST_BOOT_IDS
|
|
|
|
assert await coresys.host.logs.get_boot_id(0) == "b1c386a144fd44db8f855d7e907256f8"
|
|
|
|
# -1 is previous boot. We have 2 boots so -2 is too far
|
|
assert await coresys.host.logs.get_boot_id(-1) == "b2aca10d5ca54fb1b6fb35c85a0efca9"
|
|
with pytest.raises(ValueError, match="Logs only contain"):
|
|
await coresys.host.logs.get_boot_id(-2)
|
|
|
|
# 1 is oldest boot and count up from there. We have 2 boots so 3 is too far
|
|
assert await coresys.host.logs.get_boot_id(1) == "b2aca10d5ca54fb1b6fb35c85a0efca9"
|
|
assert await coresys.host.logs.get_boot_id(2) == "b1c386a144fd44db8f855d7e907256f8"
|
|
with pytest.raises(ValueError, match="Logs only contain"):
|
|
await coresys.host.logs.get_boot_id(3)
|
|
|
|
|
|
async def test_boot_ids_legacy_fallback(
|
|
journald_gateway: MagicMock,
|
|
coresys: CoreSys,
|
|
without_journal_gatewayd_boots: MagicMock,
|
|
):
|
|
"""Test getting boot ids using fallback."""
|
|
# Initial response has no log lines
|
|
journald_gateway.content.feed_data(b"")
|
|
journald_gateway.content.feed_eof()
|
|
|
|
# Fallback contains exactly one with a boot ID
|
|
boot_id_data = load_fixture("logs_boot_ids.txt")
|
|
reader = asyncio.StreamReader(loop=asyncio.get_running_loop())
|
|
reader.feed_data(boot_id_data.split("\n")[0].encode("utf-8"))
|
|
reader.feed_eof()
|
|
|
|
readers = [journald_gateway.content, reader]
|
|
|
|
def get_side_effect(*args, **kwargs):
|
|
journald_gateway.content = readers.pop(0)
|
|
return journald_gateway.get.return_value
|
|
|
|
journald_gateway.get.side_effect = get_side_effect
|
|
|
|
assert await coresys.host.logs.get_boot_ids() == [
|
|
"b2aca10d5ca54fb1b6fb35c85a0efca9"
|
|
]
|
|
|
|
|
|
async def test_boot_ids_native(journald_gateway: MagicMock, coresys: CoreSys):
|
|
"""Test getting boot ids from /boots endpoint."""
|
|
journald_gateway.content.feed_data(
|
|
load_fixture("systemd_journal_boots.jsons").encode("utf-8")
|
|
)
|
|
journald_gateway.content.feed_eof()
|
|
|
|
assert await coresys.host.logs.get_boot_ids() == TEST_BOOTS_IDS_NATIVE
|
|
|
|
|
|
async def test_identifiers(journald_gateway: MagicMock, coresys: CoreSys):
|
|
"""Test getting identifiers."""
|
|
journald_gateway.content.feed_data(
|
|
load_fixture("logs_identifiers.txt").encode("utf-8")
|
|
)
|
|
journald_gateway.content.feed_eof()
|
|
|
|
# Mock is large so just look for a few different types of identifiers
|
|
identifiers = await coresys.host.logs.get_identifiers()
|
|
for identifier in [
|
|
"addon_local_ssh",
|
|
"hassio_dns",
|
|
"hassio_supervisor",
|
|
"kernel",
|
|
"os-agent",
|
|
]:
|
|
assert identifier in identifiers
|
|
|
|
assert "" not in identifiers
|
|
|
|
|
|
async def test_connection_refused_handled(
|
|
journald_gateway: MagicMock, coresys: CoreSys
|
|
):
|
|
"""Test connection refused is handled with HostServiceError."""
|
|
with patch("supervisor.host.logs.ClientSession.get") as get:
|
|
get.side_effect = UnixClientConnectorError(
|
|
path="/run/systemd-journal-gatewayd.sock",
|
|
connection_key=ConnectionKey(
|
|
"localhost", None, False, False, None, None, None
|
|
),
|
|
os_error=ConnectionRefusedError("Connection refused"),
|
|
)
|
|
|
|
with pytest.raises(HostServiceError):
|
|
async with coresys.host.logs.journald_logs():
|
|
pass
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("range_header", "range_reparse"),
|
|
[
|
|
("entries=:-99:", "entries=:-99:18446744073709551615"),
|
|
("entries=:-99:100", "entries=:-99:100"),
|
|
("entries=cursor:0:100", "entries=cursor:0:100"),
|
|
],
|
|
)
|
|
async def test_range_header_reparse(
|
|
journald_gateway: MagicMock, coresys: CoreSys, range_header: str, range_reparse: str
|
|
):
|
|
"""Test that range header with trailing colon contains num_entries."""
|
|
async with coresys.host.logs.journald_logs(range_header=range_header):
|
|
journald_gateway.get.assert_called_with(
|
|
"/entries",
|
|
headers={"Accept": "text/plain", "Range": range_reparse},
|
|
params={},
|
|
timeout=None,
|
|
)
|