1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-05-20 06:38:53 +01:00
Files
supervisor/tests/api/test_os.py
T
Mike Degatano bc24fb5449 Refactor API registration to support v1/v2 via shared methods (#6769)
* Refactor API registration to support v1/v2 via shared methods

- Add AppVersion StrEnum (V1, V2) to supervisor/api/const.py
- Replace self.v2_app with self._v2_app and expose a versions property
  (dict[AppVersion, web.Application]) computed dynamically so that test
  fixtures reassigning self.webapp are automatically reflected in V1
- All _register_* methods now accept a required app: web.Application
  parameter; version-specific routes are gated with
  "if app is self.versions[AppVersion.V1/V2]:"
- load() loops over enabled_versions (V1 always, V2 when feature-flagged)
  and calls each registration method once per version, no duplication
- Static resources are registered before webapp.add_subapp() to avoid
  registering into a frozen router
- add_subapp uses self.webapp directly for readability
- Fold _register_v2_apps/_register_v2_backups/_register_v2_store into
  their respective unified methods; remove the now-defunct _register_v2_*
  helpers and the _api_apps/_api_backups/_api_store instance vars
- _register_proxy and _register_ingress updated to accept app; legacy
  /homeassistant/* proxy routes gated behind V1 conditional

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add dual v1/v2 parametrization to API tests

All 163 tests across 17 API modules that register identically on both
v1 and v2 now run against both versions via api_client_with_prefix.

- tests/api/conftest.py: advanced_logs_tester switched to
  api_client_with_prefix so log-endpoint tests are auto-parametrized;
  accepts optional v2_path_prefix kwarg for paths that differ by version
- tests/api/test_{auth,discovery,dns,docker,hardware,host,ingress,
  jobs,mounts,network,os,resolution,security,services,supervisor}.py:
  api_client -> api_client_with_prefix with path prefix unpacking
- supervisor/api/__init__.py: _register_panel() moved outside the
  version loop -- frontend static assets are V1-only
- tests/api/test_panel.py: kept on plain api_client (V1-only)

Tests intentionally kept V1-only:
- auth/discovery: use indirect api_client parametrize for addon context
- homeassistant: all tests call legacy /homeassistant/* paths (V1-only)
- jobs (4 tests): inner @Job-decorated classes register names into a
  module-level set; re-running the same test raises RuntimeError

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Extend dual v1/v2 parametrization to homeassistant and jobs tests

tests/api/conftest.py:
- Add core_api_client_with_root fixture parametrized over three paths:
    v1-core:   /core/...          (canonical v1 path)
    v1-legacy: /homeassistant/... (legacy v1 alias, same handlers)
    v2-core:   /v2/core/...       (canonical v2 path)

tests/api/test_homeassistant.py:
- Switch all 17 api_client tests to core_api_client_with_root so each
  test runs against all three access paths (v1 canonical, v1 legacy
  alias, v2 canonical), exercising every registered route

tests/api/test_jobs.py:
- Promote four inner TestClass definitions to module-level helpers
  (_JobsTreeTestHelper, _JobManualCleanupTestHelper,
  _JobsSortedTestHelper, _JobWithErrorTestHelper) so that @Job name
  registration into the global _JOB_NAMES set only happens once at
  import time rather than on each parametrized test run
- Replace closure references to outer-scope coresys with self.coresys
- Use api_client_with_prefix for dual-version coverage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix typo

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-27 23:39:47 +02:00

554 lines
18 KiB
Python

"""Test OS API."""
from unittest.mock import Mock, PropertyMock, patch
from aiohttp.test_utils import TestClient
from dbus_fast import DBusError, ErrorType
import pytest
from supervisor.coresys import CoreSys
from supervisor.dbus.agent.boards.interface import BoardProxy
from supervisor.host.control import SystemControl
from supervisor.os.manager import OSManager
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
from supervisor.resolution.data import Issue, Suggestion
from tests.common import mock_dbus_services
from tests.dbus_service_mocks.agent_boards import Boards as BoardsService
from tests.dbus_service_mocks.agent_boards_green import Green as GreenService
from tests.dbus_service_mocks.agent_boards_yellow import Yellow as YellowService
from tests.dbus_service_mocks.agent_datadisk import DataDisk as DataDiskService
from tests.dbus_service_mocks.agent_swap import Swap as SwapService
from tests.dbus_service_mocks.agent_system import System as SystemService
from tests.dbus_service_mocks.base import DBusServiceMock
from tests.dbus_service_mocks.rauc import Rauc as RaucService
@pytest.fixture(name="boards_service")
async def fixture_boards_service(
os_agent_services: dict[str, DBusServiceMock],
) -> BoardsService:
"""Return mock Boards service."""
yield os_agent_services["agent_boards"]
async def test_api_os_info(api_client_with_prefix: tuple[TestClient, str]):
"""Test os info api."""
api_client, prefix = api_client_with_prefix
resp = await api_client.get(f"{prefix}/os/info")
result = await resp.json()
for attr in (
"version",
"version_latest",
"update_available",
"board",
"boot",
"data_disk",
"boot_slots",
):
assert attr in result["data"]
async def test_api_os_info_with_agent(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test os info api for data disk."""
api_client, prefix = api_client_with_prefix
resp = await api_client.get(f"{prefix}/os/info")
result = await resp.json()
assert result["data"]["data_disk"] == "BJTD4R-0x97cde291"
async def test_api_os_info_boot_slots(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys, os_available
):
"""Test os info api for boot slots."""
api_client, prefix = api_client_with_prefix
await coresys.os.load()
resp = await api_client.get(f"{prefix}/os/info")
result = await resp.json()
assert result["data"]["boot_slots"] == {
"A": {
"state": "inactive",
"status": "good",
"version": "9.0.dev20220818",
},
"B": {"state": "booted", "status": "good", "version": "9.0.dev20220824"},
}
@pytest.mark.parametrize(
"new_disk",
["/dev/sdaaaa", "/dev/mmcblk1", "Generic-Flash-Disk-61BCDDB6"],
ids=["non-existent", "unavailable drive by path", "unavailable drive by id"],
)
async def test_api_os_datadisk_move_fail(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
new_disk: str,
os_available,
):
"""Test datadisk move to non-existent or invalid devices."""
api_client, prefix = api_client_with_prefix
resp = await api_client.post(
f"{prefix}/os/datadisk/move", json={"device": new_disk}
)
result = await resp.json()
assert result["message"] == f"'{new_disk}' not a valid data disk target!"
async def test_api_os_datadisk_list(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test datadisk list function."""
api_client, prefix = api_client_with_prefix
resp = await api_client.get(f"{prefix}/os/datadisk/list")
result = await resp.json()
assert result["data"]["devices"] == ["SSK-SSK-Storage-DF56419883D56"]
assert result["data"]["disks"] == [
{
"vendor": "SSK",
"model": "SSK Storage",
"serial": "DF56419883D56",
"id": "SSK-SSK-Storage-DF56419883D56",
"size": 250059350016,
"dev_path": "/dev/sda",
"name": "SSK SSK Storage (DF56419883D56)",
}
]
@pytest.mark.parametrize(
"new_disk",
["SSK-SSK-Storage-DF56419883D56", "/dev/sda"],
ids=["by drive id", "by device path"],
)
async def test_api_os_datadisk_migrate(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
os_agent_services: dict[str, DBusServiceMock],
new_disk: str,
os_available,
):
"""Test migrating datadisk."""
api_client, prefix = api_client_with_prefix
datadisk_service: DataDiskService = os_agent_services["agent_datadisk"]
datadisk_service.ChangeDevice.calls.clear()
with patch.object(SystemControl, "reboot") as reboot:
resp = await api_client.post(
f"{prefix}/os/datadisk/move", json={"device": new_disk}
)
assert resp.status == 200
assert datadisk_service.ChangeDevice.calls == [("/dev/sda",)]
reboot.assert_called_once()
async def test_api_os_datadisk_wipe(
api_client_with_prefix: tuple[TestClient, str],
os_agent_services: dict[str, DBusServiceMock],
os_available,
):
"""Test datadisk wipe."""
api_client, prefix = api_client_with_prefix
system_service: SystemService = os_agent_services["agent_system"]
system_service.ScheduleWipeDevice.calls.clear()
with patch.object(SystemControl, "reboot") as reboot:
resp = await api_client.post(f"{prefix}/os/datadisk/wipe")
assert resp.status == 200
assert system_service.ScheduleWipeDevice.calls == [()]
reboot.assert_called_once()
async def test_api_set_boot_slot(
api_client_with_prefix: tuple[TestClient, str],
all_dbus_services: dict[str, DBusServiceMock],
coresys: CoreSys,
os_available,
):
"""Test changing the boot slot via API."""
api_client, prefix = api_client_with_prefix
rauc_service: RaucService = all_dbus_services["rauc"]
rauc_service.Mark.calls.clear()
await coresys.os.load()
with patch.object(SystemControl, "reboot") as reboot:
resp = await api_client.post(f"{prefix}/os/boot-slot", json={"boot_slot": "A"})
assert resp.status == 200
reboot.assert_called_once()
assert rauc_service.Mark.calls == [("active", "kernel.0")]
async def test_api_set_boot_slot_invalid(
api_client_with_prefix: tuple[TestClient, str],
):
"""Test invalid calls to set boot slot."""
api_client, prefix = api_client_with_prefix
resp = await api_client.post(f"{prefix}/os/boot-slot", json={"boot_slot": "C"})
assert resp.status == 400
result = await resp.json()
assert "expected BootSlot or one of 'A', 'B'" in result["message"]
resp = await api_client.post(f"{prefix}/os/boot-slot", json={"boot_slot": "A"})
assert resp.status == 400
result = await resp.json()
assert "no Home Assistant OS available" in result["message"]
async def test_api_set_boot_slot_error(
api_client_with_prefix: tuple[TestClient, str],
all_dbus_services: dict[str, DBusServiceMock],
coresys: CoreSys,
capture_exception: Mock,
os_available,
):
"""Test changing the boot slot via API."""
api_client, prefix = api_client_with_prefix
rauc_service: RaucService = all_dbus_services["rauc"]
rauc_service.response_mark = DBusError(ErrorType.FAILED, "fail")
await coresys.os.load()
resp = await api_client.post(f"{prefix}/os/boot-slot", json={"boot_slot": "A"})
assert resp.status == 400
result = await resp.json()
assert result["message"] == "Can't mark A as active!"
capture_exception.assert_called_once()
async def test_api_board_yellow_info(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test yellow board info."""
api_client, prefix = api_client_with_prefix
resp = await api_client.get(f"{prefix}/os/boards/yellow")
assert resp.status == 200
result = await resp.json()
assert result["data"]["disk_led"] is True
assert result["data"]["heartbeat_led"] is True
assert result["data"]["power_led"] is True
assert (await api_client.get(f"{prefix}/os/boards/green")).status == 400
assert (await api_client.get(f"{prefix}/os/boards/supervised")).status == 400
assert (await api_client.get(f"{prefix}/os/boards/not-real")).status == 400
async def test_api_board_yellow_options(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
os_agent_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
):
"""Test yellow board options."""
api_client, prefix = api_client_with_prefix
yellow_service: YellowService = os_agent_services["agent_boards_yellow"]
assert coresys.dbus.agent.board.yellow.disk_led is True
assert coresys.dbus.agent.board.yellow.heartbeat_led is True
assert coresys.dbus.agent.board.yellow.power_led is True
assert len(coresys.resolution.issues) == 0
with patch.object(BoardProxy, "save_data") as save_data:
resp = await api_client.post(
f"{prefix}/os/boards/yellow",
json={"disk_led": False, "heartbeat_led": False, "power_led": False},
)
assert resp.status == 200
save_data.assert_called_once()
await yellow_service.ping()
assert coresys.dbus.agent.board.yellow.disk_led is False
assert coresys.dbus.agent.board.yellow.heartbeat_led is False
assert coresys.dbus.agent.board.yellow.power_led is False
assert (
Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM)
in coresys.resolution.issues
)
assert (
Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM)
in coresys.resolution.suggestions
)
async def test_api_board_green_info(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
boards_service: BoardsService,
):
"""Test green board info."""
api_client, prefix = api_client_with_prefix
await mock_dbus_services({"agent_boards_green": None}, coresys.dbus.bus)
boards_service.board = "Green"
await coresys.dbus.agent.board.connect(coresys.dbus.bus)
resp = await api_client.get(f"{prefix}/os/boards/green")
assert resp.status == 200
result = await resp.json()
assert result["data"]["activity_led"] is True
assert result["data"]["power_led"] is True
assert result["data"]["system_health_led"] is True
assert (await api_client.get(f"{prefix}/os/boards/yellow")).status == 400
assert (await api_client.get(f"{prefix}/os/boards/supervised")).status == 400
assert (await api_client.get(f"{prefix}/os/boards/not-real")).status == 400
async def test_api_board_green_options(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
boards_service: BoardsService,
):
"""Test yellow board options."""
api_client, prefix = api_client_with_prefix
green_service: GreenService = (
await mock_dbus_services({"agent_boards_green": None}, coresys.dbus.bus)
)["agent_boards_green"]
boards_service.board = "Green"
await coresys.dbus.agent.board.connect(coresys.dbus.bus)
assert coresys.dbus.agent.board.green.activity_led is True
assert coresys.dbus.agent.board.green.power_led is True
assert coresys.dbus.agent.board.green.user_led is True
assert len(coresys.resolution.issues) == 0
with patch.object(BoardProxy, "save_data") as save_data:
resp = await api_client.post(
f"{prefix}/os/boards/green",
json={
"activity_led": False,
"power_led": False,
"system_health_led": False,
},
)
assert resp.status == 200
save_data.assert_called_once()
await green_service.ping()
assert coresys.dbus.agent.board.green.activity_led is False
assert coresys.dbus.agent.board.green.power_led is False
assert coresys.dbus.agent.board.green.user_led is False
assert len(coresys.resolution.issues) == 0
async def test_api_board_supervised_info(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
boards_service: BoardsService,
):
"""Test supervised board info."""
api_client, prefix = api_client_with_prefix
await mock_dbus_services({"agent_boards_supervised": None}, coresys.dbus.bus)
boards_service.board = "Supervised"
await coresys.dbus.agent.board.connect(coresys.dbus.bus)
with patch("supervisor.os.manager.CPE.get_product", return_value=["not-hassos"]):
await coresys.os.load()
assert (await api_client.get(f"{prefix}/os/boards/supervised")).status == 200
assert (
await api_client.post(f"{prefix}/os/boards/supervised", json={})
).status == 405
assert (await api_client.get(f"{prefix}/os/boards/yellow")).status == 400
assert (await api_client.get(f"{prefix}/os/boards/not-real")).status == 400
async def test_api_board_other_info(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
boards_service: BoardsService,
):
"""Test info for other board without dbus object."""
api_client, prefix = api_client_with_prefix
boards_service.board = "not-real"
await coresys.dbus.agent.board.connect(coresys.dbus.bus)
with patch.object(OSManager, "board", new=PropertyMock(return_value="not-real")):
assert (await api_client.get(f"{prefix}/os/boards/not-real")).status == 200
assert (
await api_client.post(f"{prefix}/os/boards/not-real", json={})
).status == 405
assert (await api_client.get(f"{prefix}/os/boards/yellow")).status == 400
assert (await api_client.get(f"{prefix}/os/boards/supervised")).status == 400
@pytest.mark.parametrize("os_available", ["15.0"], indirect=True)
async def test_api_config_swap_info(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys, os_available
):
"""Test swap info."""
api_client, prefix = api_client_with_prefix
await coresys.dbus.agent.swap.connect(coresys.dbus.bus)
resp = await api_client.get(f"{prefix}/os/config/swap")
assert resp.status == 200
result = await resp.json()
assert result["data"]["swap_size"] == "1M"
assert result["data"]["swappiness"] == 1
@pytest.mark.parametrize("os_available", ["15.0"], indirect=True)
async def test_api_config_swap_options(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
os_agent_services: dict[str, DBusServiceMock],
os_available,
):
"""Test swap setting."""
api_client, prefix = api_client_with_prefix
swap_service: SwapService = os_agent_services["agent_swap"]
await coresys.dbus.agent.swap.connect(coresys.dbus.bus)
assert coresys.dbus.agent.swap.swap_size == "1M"
assert coresys.dbus.agent.swap.swappiness == 1
resp = await api_client.post(
f"{prefix}/os/config/swap",
json={
"swap_size": "2M",
"swappiness": 10,
},
)
assert resp.status == 200
await swap_service.ping()
assert coresys.dbus.agent.swap.swap_size == "2M"
assert coresys.dbus.agent.swap.swappiness == 10
assert (
Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM)
in coresys.resolution.issues
)
assert (
Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM)
in coresys.resolution.suggestions
)
# test setting only the swap size
resp = await api_client.post(
f"{prefix}/os/config/swap",
json={
"swap_size": "10M",
},
)
assert resp.status == 200
await swap_service.ping()
assert coresys.dbus.agent.swap.swap_size == "10M"
assert coresys.dbus.agent.swap.swappiness == 10
# test setting only the swappiness
resp = await api_client.post(
f"{prefix}/os/config/swap",
json={
"swappiness": 100,
},
)
assert resp.status == 200
await swap_service.ping()
assert coresys.dbus.agent.swap.swap_size == "10M"
assert coresys.dbus.agent.swap.swappiness == 100
@pytest.mark.parametrize("os_available", ["15.0"], indirect=True)
async def test_api_config_swap_options_no_reboot(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
os_agent_services: dict[str, DBusServiceMock],
os_available,
):
"""Test no resolution is shown when setting are submitted empty or unchanged."""
api_client, prefix = api_client_with_prefix
await coresys.dbus.agent.swap.connect(coresys.dbus.bus)
# empty options
resp = await api_client.post(
f"{prefix}/os/config/swap",
json={},
)
assert resp.status == 200
assert (
Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM)
not in coresys.resolution.issues
)
assert (
Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM)
not in coresys.resolution.suggestions
)
# no change
resp = await api_client.post(
f"{prefix}/os/config/swap",
json={
"swappiness": coresys.dbus.agent.swap.swappiness,
"swap_size": coresys.dbus.agent.swap.swap_size,
},
)
assert resp.status == 200
assert (
Issue(IssueType.REBOOT_REQUIRED, ContextType.SYSTEM)
not in coresys.resolution.issues
)
assert (
Suggestion(SuggestionType.EXECUTE_REBOOT, ContextType.SYSTEM)
not in coresys.resolution.suggestions
)
async def test_api_config_swap_not_os(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
os_agent_services: dict[str, DBusServiceMock],
):
"""Test 404 is returned for swap endpoints if not running on HAOS."""
api_client, prefix = api_client_with_prefix
await coresys.dbus.agent.swap.connect(coresys.dbus.bus)
resp = await api_client.get(f"{prefix}/os/config/swap")
assert resp.status == 404
resp = await api_client.post(
f"{prefix}/os/config/swap",
json={
"swap_size": "2M",
"swappiness": 10,
},
)
assert resp.status == 404
@pytest.mark.parametrize("os_available", ["14.2"], indirect=True)
async def test_api_config_swap_old_os(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
os_agent_services: dict[str, DBusServiceMock],
os_available,
):
"""Test 404 is returned for swap endpoints if OS is older than 15.0."""
api_client, prefix = api_client_with_prefix
await coresys.dbus.agent.swap.connect(coresys.dbus.bus)
resp = await api_client.get(f"{prefix}/os/config/swap")
assert resp.status == 404
resp = await api_client.post(
f"{prefix}/os/config/swap",
json={
"swap_size": "2M",
"swappiness": 10,
},
)
assert resp.status == 404