1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-05-19 14:18:53 +01:00
Files
supervisor/tests/api/conftest.py
T
Mike Degatano eb3c388618 Migrate persisted 'addon' field to 'app' in config files (#6786)
* Migrate persisted 'addon' field to 'app' in discovery and services config

Rename the 'addon' key to 'app' in persisted configuration files for
discovery messages (discovery.json), service modules (services.json),
and supervisor config (supervisor.json), as part of the broader
addon->app terminology migration.

Changes:
- Add ATTR_ADDON = "addon" to const.py for V1 API compat/migration
- Add ATTR_ADDONS_CUSTOM_LIST = "addons_custom_list" to const.py for migration
- Change ATTR_APPS_CUSTOM_LIST value from "addons_custom_list" to "apps_custom_list"
- Add _migrate_supervisor_config() schema pre-processor in validate.py to
  transparently load old supervisor.json files using the old key
- Add ATTR_ADDON to services/const.py; change ATTR_APP value to "app"
- Add _migrate_addon_to_app() pre-processors to MQTT, MySQL, and discovery
  schemas to load old config files that used the "addon" key
- Rename Message.addon -> Message.app in Discovery and update all references
- Keep hassio_push/discovery payload using "addon" key for HA compatibility
- GET /services/{service} and GET /discovery: V1 returns "addon" key,
  V2 returns "app" key, via dedicated _v1 handler methods following the
  backups/store pattern, registered with AppVersion guards in
  _register_services() and _register_discovery()
- Broaden FileConfiguration schema type annotation to accept vol.All
  validators in addition to vol.Schema

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

* Add schema migration tests for addon->app config key rename

Test that backwards-compatible migration of old 'addon'/'addons_custom_list'
keys to 'app'/'apps_custom_list' works correctly in all affected schemas,
and that the new keys are accepted without modification.

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

* Add an __init__ to discovery tests

* Add app_api_client_with_prefix fixture and update V1/V2 tests

Move the app-level V1/V2 fixture to tests/api/conftest.py as
app_api_client_with_prefix for use across any endpoint that requires
app-level credentials (services_role, app.discovery, etc.).

- Add app_api_client_with_prefix fixture to conftest.py
- Update test_set_service_already_provided and test_del_service_not_provided
  to use app_api_client_with_prefix (covers both v1 and v2)
- Add test_get_service_v1_v2_keys asserting addon/app key per version
- Update test_api_discovery_forbidden, test_api_send_del_discovery,
  test_api_invalid_discovery to use app_api_client_with_prefix
- Split test_discovery_not_found into test_discovery_not_found_get
  (uses api_client_with_prefix, GET requires homeassistant) and
  test_discovery_not_found_delete (uses app_api_client_with_prefix)
- Add test_get_discovery_v1_v2_keys asserting addon/app key per version

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 11:18:47 +02:00

302 lines
9.6 KiB
Python

"""Fixtures for API tests."""
from collections.abc import Awaitable, Callable
from unittest.mock import ANY, AsyncMock, MagicMock
from aiohttp import web
from aiohttp.test_utils import TestClient
import pytest
from supervisor.addons.addon import App
from supervisor.api import RestAPI
from supervisor.const import REQUEST_FROM, FeatureFlag
from supervisor.coresys import CoreSys
from supervisor.host.const import LogFormat, LogFormatter
DEFAULT_LOG_RANGE = "entries=:-99:100"
DEFAULT_LOG_RANGE_FOLLOW = "entries=:-99:18446744073709551615"
async def _common_test_api_advanced_logs(
path_prefix: str,
syslog_identifier: str,
formatter: LogFormatter,
api_client: TestClient,
journald_logs: MagicMock,
coresys: CoreSys,
os_available: None,
journal_logs_reader: MagicMock,
):
"""Template for tests of endpoints using advanced logs."""
resp = await api_client.get(f"{path_prefix}/logs")
assert resp.status == 200
assert resp.content_type == "text/plain"
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": syslog_identifier},
range_header=DEFAULT_LOG_RANGE,
accept=LogFormat.JOURNAL,
)
journal_logs_reader.assert_called_with(ANY, formatter, False)
journald_logs.reset_mock()
journal_logs_reader.reset_mock()
resp = await api_client.get(f"{path_prefix}/logs?no_colors")
assert resp.status == 200
assert resp.content_type == "text/plain"
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": syslog_identifier},
range_header=DEFAULT_LOG_RANGE,
accept=LogFormat.JOURNAL,
)
journal_logs_reader.assert_called_with(ANY, formatter, True)
journald_logs.reset_mock()
journal_logs_reader.reset_mock()
resp = await api_client.get(f"{path_prefix}/logs/follow")
assert resp.status == 200
assert resp.content_type == "text/plain"
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": syslog_identifier, "follow": ""},
range_header=DEFAULT_LOG_RANGE_FOLLOW,
accept=LogFormat.JOURNAL,
)
journal_logs_reader.assert_called_with(ANY, formatter, False)
journald_logs.reset_mock()
journal_logs_reader.reset_mock()
mock_response = MagicMock()
mock_response.text = AsyncMock(
return_value='{"CONTAINER_LOG_EPOCH": "12345"}\n{"CONTAINER_LOG_EPOCH": "12345"}\n'
)
journald_logs.return_value.__aenter__.return_value = mock_response
resp = await api_client.get(f"{path_prefix}/logs/latest")
assert resp.status == 200
assert journald_logs.call_count == 2
# Check the first call for getting epoch
epoch_call = journald_logs.call_args_list[0]
assert epoch_call[1]["params"] == {"CONTAINER_NAME": syslog_identifier}
assert epoch_call[1]["range_header"] == "entries=:-1:2"
# Check the second call for getting logs with the epoch
logs_call = journald_logs.call_args_list[1]
assert logs_call[1]["params"]["SYSLOG_IDENTIFIER"] == syslog_identifier
assert logs_call[1]["params"]["CONTAINER_LOG_EPOCH"] == "12345"
assert logs_call[1]["range_header"] == "entries=:0:18446744073709551615"
journal_logs_reader.assert_called_with(ANY, formatter, True)
journald_logs.reset_mock()
journal_logs_reader.reset_mock()
resp = await api_client.get(f"{path_prefix}/logs/boots/0")
assert resp.status == 200
assert resp.content_type == "text/plain"
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": syslog_identifier, "_BOOT_ID": "ccc"},
range_header=DEFAULT_LOG_RANGE,
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
resp = await api_client.get(f"{path_prefix}/logs/boots/0/follow")
assert resp.status == 200
assert resp.content_type == "text/plain"
journald_logs.assert_called_once_with(
params={
"SYSLOG_IDENTIFIER": syslog_identifier,
"_BOOT_ID": "ccc",
"follow": "",
},
range_header=DEFAULT_LOG_RANGE_FOLLOW,
accept=LogFormat.JOURNAL,
)
@pytest.fixture
async def advanced_logs_tester(
api_client_with_prefix: tuple[TestClient, str],
journald_logs: MagicMock,
coresys: CoreSys,
os_available,
journal_logs_reader: MagicMock,
) -> Callable[..., Awaitable[None]]:
"""Fixture that returns a function to test advanced logs endpoints.
This allows tests to avoid explicitly passing all the required fixtures.
Usage:
async def test_my_logs(advanced_logs_tester):
await advanced_logs_tester("/path/prefix", "syslog_identifier")
"""
api_client, api_prefix = api_client_with_prefix
async def test_logs(
path_prefix: str,
syslog_identifier: str,
formatter: LogFormatter = LogFormatter.PLAIN,
*,
v2_path_prefix: str | None = None,
):
effective_path = (
v2_path_prefix
if (api_prefix and v2_path_prefix is not None)
else path_prefix
)
await _common_test_api_advanced_logs(
f"{api_prefix}{effective_path}",
syslog_identifier,
formatter,
api_client,
journald_logs,
coresys,
os_available,
journal_logs_reader,
)
return test_logs
@pytest.fixture(name="api_client_v2")
async def fixture_api_client_v2(aiohttp_client, coresys: CoreSys) -> TestClient:
"""Fixture for RestAPI client with v2 API enabled."""
coresys.config.set_feature_flag(FeatureFlag.SUPERVISOR_V2_API, True)
@web.middleware
async def _security_middleware(request: web.Request, handler: web.RequestHandler):
request[REQUEST_FROM] = coresys.homeassistant
return await handler(request)
api = RestAPI(coresys)
api.webapp = web.Application(middlewares=[_security_middleware])
api.start = AsyncMock()
await api.load()
yield await aiohttp_client(api.webapp)
@pytest.fixture(
name="api_client_with_prefix",
params=[pytest.param("", id="v1"), pytest.param("/v2", id="v2")],
)
async def fixture_api_client_with_prefix(
request: pytest.FixtureRequest,
api_client: TestClient,
api_client_v2: TestClient,
) -> tuple[TestClient, str]:
"""Fixture providing (client, path_prefix) for both v1 and v2 API paths.
Use this for APIs whose behavior is identical between v1 and v2 to confirm
both versions work correctly.
"""
if request.param == "":
return api_client, ""
return api_client_v2, "/v2"
@pytest.fixture(
name="app_api_client_with_prefix",
params=[pytest.param("", id="v1"), pytest.param("/v2", id="v2")],
)
async def fixture_app_api_client_with_prefix(
request: pytest.FixtureRequest,
aiohttp_client,
coresys: CoreSys,
install_app_ssh: App,
) -> tuple[TestClient, str]:
"""Fixture providing (client, path_prefix) for APIs on both v1 and v2 that require app-level credentials.
Unlike api_client_with_prefix (which uses homeassistant as REQUEST_FROM), this
fixture uses an installed app so endpoints with app-level access checks (e.g.
services_role, app.discovery) work correctly.
"""
prefix: str = request.param
if prefix == "/v2":
coresys.config.set_feature_flag(FeatureFlag.SUPERVISOR_V2_API, True)
@web.middleware
async def _security_middleware(req: web.Request, handler: web.RequestHandler):
req[REQUEST_FROM] = install_app_ssh
return await handler(req)
api = RestAPI(coresys)
api.webapp = web.Application(middlewares=[_security_middleware])
api.start = AsyncMock()
await api.load()
return await aiohttp_client(api.webapp), prefix
@pytest.fixture(
name="core_api_client_with_root",
params=[
pytest.param("/core", id="v1-core"),
pytest.param("/homeassistant", id="v1-legacy"),
pytest.param("/v2/core", id="v2-core"),
],
)
async def fixture_core_api_client_with_root(
request: pytest.FixtureRequest,
api_client: TestClient,
api_client_v2: TestClient,
) -> tuple[TestClient, str]:
"""Fixture providing (client, path_root) for Home Assistant Core API endpoints.
Parametrizes over all three registered access paths:
v1-core: /core/... (canonical v1 path)
v1-legacy: /homeassistant/... (legacy v1 alias, same handlers)
v2-core: /v2/core/... (canonical v2 path)
"""
root: str = request.param
client = api_client_v2 if root.startswith("/v2") else api_client
return client, root
@pytest.fixture(
name="app_api_client_with_root",
params=[pytest.param("/addons", id="v1"), pytest.param("/v2/apps", id="v2")],
)
async def fixture_app_api_client_with_root(
request: pytest.FixtureRequest,
api_client: TestClient,
api_client_v2: TestClient,
) -> tuple[TestClient, str]:
"""Fixture providing (client, path_root) for both v1 and v2 app management paths.
v1 root: /addons/{slug}/...
v2 root: /v2/apps/{slug}/...
"""
root: str = request.param
client = api_client if root == "/addons" else api_client_v2
return client, root
@pytest.fixture(
name="store_app_api_client_with_root",
params=[
pytest.param("store/addons", id="v1"),
pytest.param("v2/store/apps", id="v2"),
],
)
async def fixture_store_app_api_client_with_root(
request: pytest.FixtureRequest,
api_client: TestClient,
api_client_v2: TestClient,
) -> tuple[TestClient, str]:
"""Fixture providing (client, resource_root) for both v1 and v2 store app paths.
v1 root: store/addons/{slug}/...
v2 root: v2/store/apps/{slug}/...
"""
resource: str = request.param
client = api_client_v2 if resource.startswith("v2/") else api_client
return client, resource