Files
supervisor/tests/api/test_resolution.py
T
ccdd8c1ef4 Add repair to move local data blocking a mount target (#7089)
* Add repair to move local data blocking a mount target

When an add-on writes into a media/share directory while its network
mount is not in place (#7037), the local data blocks re-creating the
mount: mounting over a non-empty directory is refused. Until now this
failed silently at Supervisor startup — the bind mounts were created as
fire-and-forget tasks — and the only way out was to remove the data
manually over SSH/Samba and re-create the mount via the API.

Surface the condition as a new mount_target_not_empty issue and offer a
move_local_data suggestion. The fixup moves the blocking data to a
<name>_local_recovery folder in a user-accessible location — media or
share for bind mount targets, local backup storage for backup mounts
(their data mount directory is not reachable for users) — then reloads
the mount. Nothing is deleted; users can inspect and clean up the
recovered data via the media browser or the share and backup folders.

Bind mount failures during load are now awaited and routed into
resolution issues instead of being swallowed as fire-and-forget tasks;
bind failures other than blocking local data create the existing
mount_failed issue. A successful mount reload dismisses a stale local
data issue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Attach move local data suggestion to the mount failed issue

Review feedback on the repair: rather than introducing a separate
mount_target_not_empty issue type, keep a single mount failed issue
per mount and offer moving the blocking data as an additional
suggestion alongside reload and remove. Reload stays available for
users who prefer to clear the data themselves, and at most one repair
exists per mount. Adding is idempotent, so an already-raised mount
failed issue just gains the extra suggestion.

When re-creating the bind mount after a successful reload fails on
blocking local data, the mount failed issue is re-added together with
the move suggestion, since the reload already dismissed it.

This also resolves the reviewer note about not-a-directory conflicts
being reported under a not-empty issue type: the issue type no longer
encodes the filesystem detail, while the error messages keep the
distinction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep an empty directory in place after relocating local data

If remounting fails after the local data was moved aside (e.g. the
server is unreachable at that moment), the renamed directory left
nothing behind: media/share consumers saw the folder disappear
entirely. Recreate an empty directory right after the rename so the
path stays present regardless of whether the remount succeeds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop move local data suggestion once the data was moved

When the remount after relocating local data fails (e.g. the server is
unreachable at that moment), the mount failed issue stays — but the
move suggestion stayed with it, offering to move data that is no
longer in the way. Dismiss the suggestion after the relocation step so
only reload and remove remain for the leftover failure. Detection
re-adds the move suggestion if local data blocks the target again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Filter Core-facing suggestions by minimum Core version

The fix flow translations for a new suggestion ship with a Core
release. Older Core frontends render an unknown suggestion as an
empty, unlabeled menu entry in the repair fix flow. Filter such
suggestions from Core-facing output — the issue events sent over the
websocket and the resolution API responses when the caller is Home
Assistant — until the connected Core is new enough. Other API
consumers like the CLI always see the full suggestion list.

The move_local_data suggestion requires Core 2026.9.0b0, the release
its fix flow translations are targeted at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make fixup failure tests independent of error propagation behavior

Suppress a potential ResolutionFixupError from the failing fixup calls
so the tests pass both while fixup errors are swallowed and once they
propagate to the caller (#7150).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review: one recovery folder, check OSError for known issues

Move all local data blocking a mount into a single recovery folder so
the user finds it as one fix: when more than one directory holds data,
later ones become subfolders named after their parent directory
instead of numbered sibling folders.

Also run OSError from the relocation through check_oserror to pick up
known filesystem issues like corruption (bad message).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Only filter Core-facing suggestions on v1 surfaces

Per review: no Core version predating the repair suggestion filtering
in its own fix flow (home-assistant/core#179540) supports the v2 API,
so the Supervisor-side compatibility filter is only needed where old
Core versions actually look. Filter the v1 resolution endpoints and
the legacy websocket issue payloads; the v2 endpoints and v2 event
payloads always carry the full suggestion list. The suggestions for
issue endpoint gets a v1 handler for this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 23:40:47 +02:00

563 lines
20 KiB
Python

"""Test Resolution API."""
import asyncio
from http import HTTPStatus
from unittest.mock import AsyncMock, PropertyMock, patch
from aiohttp.test_utils import TestClient
from awesomeversion import AwesomeVersion
import pytest
from supervisor.const import (
ATTR_ISSUES,
ATTR_SUGGESTIONS,
ATTR_UNHEALTHY,
ATTR_UNSUPPORTED,
CoreState,
FeatureFlag,
)
from supervisor.coresys import CoreSys
from supervisor.exceptions import ResolutionError
from supervisor.homeassistant.const import WSType
from supervisor.resolution.const import (
ContextType,
IssueType,
SuggestionType,
UnhealthyReason,
UnsupportedReason,
)
from supervisor.resolution.data import Issue, Suggestion
async def test_api_resolution_base(
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
):
"""Test resolution manager api."""
api_client, prefix = api_client_with_prefix
coresys.resolution.add_unsupported_reason(UnsupportedReason.OS)
coresys.resolution.add_suggestion(
Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM)
)
coresys.resolution.create_issue(IssueType.FREE_SPACE, ContextType.SYSTEM)
resp = await api_client.get(f"{prefix}/resolution/info")
result = await resp.json()
assert UnsupportedReason.OS in result["data"][ATTR_UNSUPPORTED]
assert (
result["data"][ATTR_SUGGESTIONS][-1]["type"] == SuggestionType.CLEAR_FULL_BACKUP
)
assert result["data"][ATTR_ISSUES][-1]["type"] == IssueType.FREE_SPACE
async def test_api_resolution_dismiss_suggestion(
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
):
"""Test resolution manager dismiss suggestion api."""
api_client, prefix = api_client_with_prefix
coresys.resolution.add_suggestion(
clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM)
)
assert coresys.resolution.suggestions[-1].type == SuggestionType.CLEAR_FULL_BACKUP
await api_client.delete(f"{prefix}/resolution/suggestion/{clear_backup.uuid}")
assert clear_backup not in coresys.resolution.suggestions
async def test_api_resolution_apply_suggestion(
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
):
"""Test resolution manager suggestion apply api."""
api_client, prefix = api_client_with_prefix
coresys.resolution.add_suggestion(
clear_backup := Suggestion(SuggestionType.CLEAR_FULL_BACKUP, ContextType.SYSTEM)
)
coresys.resolution.add_suggestion(
create_backup := Suggestion(
SuggestionType.CREATE_FULL_BACKUP, ContextType.SYSTEM
)
)
mock_backups = AsyncMock()
mock_health = AsyncMock()
coresys.backups.do_backup_full = mock_backups
coresys.resolution.healthcheck = mock_health
await api_client.post(f"{prefix}/resolution/suggestion/{clear_backup.uuid}")
await api_client.post(f"{prefix}/resolution/suggestion/{create_backup.uuid}")
assert clear_backup not in coresys.resolution.suggestions
assert create_backup not in coresys.resolution.suggestions
assert mock_backups.called
assert mock_health.called
with pytest.raises(ResolutionError):
await coresys.resolution.apply_suggestion(clear_backup)
async def test_api_resolution_suggestions_filtered_for_old_core(
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
):
"""Test v1 responses hide suggestions the Core version cannot present.
The v2 API never filters: no Core version predating the suggestion
filtering in its repair flow supports v2.
"""
api_client, prefix = api_client_with_prefix
coresys.resolution.add_issue(
issue := Issue(IssueType.MOUNT_FAILED, ContextType.MOUNT, reference="test"),
suggestions=[SuggestionType.MOVE_LOCAL_DATA, SuggestionType.EXECUTE_RELOAD],
)
all_types = {"execute_reload", "move_local_data"}
for version, expected_types in [
(AwesomeVersion("2026.8.3"), {"execute_reload"} if not prefix else all_types),
(AwesomeVersion("2026.9.0b0"), all_types),
]:
with patch.object(
type(coresys.homeassistant),
"version",
new=PropertyMock(return_value=version),
):
resp = await api_client.get(f"{prefix}/resolution/info")
body = await resp.json()
assert {
suggestion["type"] for suggestion in body["data"]["suggestions"]
} == expected_types
resp = await api_client.get(
f"{prefix}/resolution/issue/{issue.uuid}/suggestions"
)
body = await resp.json()
assert {
suggestion["type"] for suggestion in body["data"]["suggestions"]
} == expected_types
async def test_api_resolution_dismiss_issue(
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
):
"""Test resolution manager issue apply api."""
api_client, prefix = api_client_with_prefix
coresys.resolution.add_issue(
updated_failed := Issue(IssueType.UPDATE_FAILED, ContextType.SYSTEM)
)
assert coresys.resolution.issues[-1].type == IssueType.UPDATE_FAILED
await api_client.delete(f"{prefix}/resolution/issue/{updated_failed.uuid}")
assert updated_failed not in coresys.resolution.issues
async def test_api_resolution_unhealthy(
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
):
"""Test resolution manager api."""
api_client, prefix = api_client_with_prefix
coresys.resolution.add_unhealthy_reason(UnhealthyReason.DOCKER)
resp = await api_client.get(f"{prefix}/resolution/info")
result = await resp.json()
assert result["data"][ATTR_UNHEALTHY][-1] == UnhealthyReason.DOCKER
async def test_api_resolution_check_options(
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
):
"""Test client API with checks options."""
api_client, prefix = api_client_with_prefix
free_space = coresys.resolution.check.get("free_space")
assert free_space.enabled
await api_client.post(
f"{prefix}/resolution/check/{free_space.slug}/options", json={"enabled": False}
)
assert not free_space.enabled
await api_client.post(
f"{prefix}/resolution/check/{free_space.slug}/options", json={"enabled": True}
)
assert free_space.enabled
async def test_api_resolution_check_run(
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
):
"""Test client API with run check."""
api_client, prefix = api_client_with_prefix
await coresys.core.set_state(CoreState.RUNNING)
free_space = coresys.resolution.check.get("free_space")
free_space.run_check = AsyncMock()
await api_client.post(f"{prefix}/resolution/check/{free_space.slug}/run")
assert free_space.run_check.called
async def test_api_resolution_suggestions_for_issue(
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
):
"""Test getting suggestions that fix an issue."""
api_client, prefix = api_client_with_prefix
coresys.resolution.add_issue(
corrupt_repo := Issue(IssueType.CORRUPT_REPOSITORY, ContextType.STORE, "repo_1")
)
resp = await api_client.get(
f"{prefix}/resolution/issue/{corrupt_repo.uuid}/suggestions"
)
result = await resp.json()
assert result["data"]["suggestions"] == []
coresys.resolution.add_suggestion(
execute_reset := Suggestion(
SuggestionType.EXECUTE_RESET, ContextType.STORE, "repo_1"
)
)
coresys.resolution.add_suggestion(
execute_remove := Suggestion(
SuggestionType.EXECUTE_REMOVE, ContextType.STORE, "repo_1"
)
)
resp = await api_client.get(
f"{prefix}/resolution/issue/{corrupt_repo.uuid}/suggestions"
)
result = await resp.json()
suggestion = [
su for su in result["data"]["suggestions"] if su["uuid"] == execute_reset.uuid
]
assert len(suggestion) == 1
assert suggestion[0]["auto"] is True
suggestion = [
su for su in result["data"]["suggestions"] if su["uuid"] == execute_remove.uuid
]
assert len(suggestion) == 1
assert suggestion[0]["auto"] is False
@pytest.mark.parametrize(
("method", "url"),
[("delete", "/resolution/issue/bad"), ("get", "/resolution/issue/bad/suggestions")],
)
async def test_issue_not_found(
api_client_with_prefix: tuple[TestClient, str], method: str, url: str
):
"""Test issue not found error."""
api_client, prefix = api_client_with_prefix
resp = await api_client.request(method, f"{prefix}{url}")
assert resp.status == 404
body = await resp.json()
assert body["message"] == "Issue bad does not exist"
assert body["error_key"] == "resolution_issue_not_found_error"
assert body["extra_fields"] == {"uuid": "bad"}
@pytest.mark.parametrize(
("method", "url"),
[("delete", "/resolution/suggestion/bad"), ("post", "/resolution/suggestion/bad")],
)
async def test_suggestion_not_found(
api_client_with_prefix: tuple[TestClient, str], method: str, url: str
):
"""Test suggestion not found error."""
api_client, prefix = api_client_with_prefix
resp = await api_client.request(method, f"{prefix}{url}")
assert resp.status == 404
body = await resp.json()
assert body["message"] == "Suggestion bad does not exist"
assert body["error_key"] == "resolution_suggestion_not_found_error"
assert body["extra_fields"] == {"uuid": "bad"}
@pytest.mark.parametrize(
("method", "url"),
[("post", "/resolution/check/bad/options"), ("post", "/resolution/check/bad/run")],
)
async def test_check_not_found(
api_client_with_prefix: tuple[TestClient, str], method: str, url: str
):
"""Test check not found error."""
api_client, prefix = api_client_with_prefix
resp = await api_client.request(method, f"{prefix}{url}")
assert resp.status == HTTPStatus.NOT_FOUND
body = await resp.json()
assert body["message"] == "Check 'bad' does not exist"
assert body["error_key"] == "resolution_check_not_found_error"
assert body["extra_fields"] == {"check": "bad"}
@pytest.mark.parametrize(
("issue_type", "legacy_issue_type"),
[
(IssueType.DEPRECATED_APP, "deprecated_addon"),
(IssueType.DEPRECATED_ARCH_APP, "deprecated_arch_addon"),
(IssueType.DETACHED_APP_MISSING, "detached_addon_missing"),
(IssueType.DETACHED_APP_REMOVED, "detached_addon_removed"),
],
)
async def test_api_resolution_info_v1_uses_legacy_names(
coresys: CoreSys, api_client: TestClient, issue_type: str, legacy_issue_type: str
):
"""Test v1 resolution info uses legacy issue type and check slug names."""
coresys.resolution.add_issue(Issue(issue_type, ContextType.ADDON, reference="test"))
resp = await api_client.get("/resolution/info")
result = await resp.json()
# V1 should return legacy issue type name
issue_types = [issue["type"] for issue in result["data"][ATTR_ISSUES]]
assert legacy_issue_type in issue_types
assert issue_type not in issue_types
# V1 should return legacy check slugs
check_slugs = [check["slug"] for check in result["data"]["checks"]]
assert "addon_pwned" in check_slugs
assert "deprecated_addon" in check_slugs
assert "deprecated_arch_addon" in check_slugs
assert "detached_addon_missing" in check_slugs
assert "detached_addon_removed" in check_slugs
# Should NOT have new names
assert "app_pwned" not in check_slugs
assert "deprecated_app" not in check_slugs
assert "deprecated_arch_app" not in check_slugs
assert "detached_app_missing" not in check_slugs
assert "detached_app_removed" not in check_slugs
@pytest.mark.parametrize(
"issue_type",
[
IssueType.DEPRECATED_APP,
IssueType.DEPRECATED_ARCH_APP,
IssueType.DETACHED_APP_MISSING,
IssueType.DETACHED_APP_REMOVED,
],
)
async def test_api_resolution_info_v2_uses_new_names(
coresys: CoreSys, api_client_v2: TestClient, issue_type: str
):
"""Test v2 resolution info uses new issue type and check slug names."""
coresys.resolution.add_issue(Issue(issue_type, ContextType.ADDON, reference="test"))
resp = await api_client_v2.get("/v2/resolution/info")
result = await resp.json()
# V2 should return new issue type name
issue_types = [issue["type"] for issue in result["data"][ATTR_ISSUES]]
assert issue_type in issue_types
# V2 should return new check slugs
check_slugs = [check["slug"] for check in result["data"]["checks"]]
assert "app_pwned" in check_slugs
assert "deprecated_app" in check_slugs
assert "deprecated_arch_app" in check_slugs
assert "detached_app_missing" in check_slugs
assert "detached_app_removed" in check_slugs
# Should NOT have legacy names
assert "addon_pwned" not in check_slugs
assert "deprecated_addon" not in check_slugs
assert "deprecated_arch_addon" not in check_slugs
assert "detached_addon_missing" not in check_slugs
assert "detached_addon_removed" not in check_slugs
@pytest.mark.parametrize(
("issue_type", "legacy_issue_type"),
[
(IssueType.DEPRECATED_APP, "deprecated_addon"),
(IssueType.DEPRECATED_ARCH_APP, "deprecated_arch_addon"),
(IssueType.DETACHED_APP_MISSING, "detached_addon_missing"),
(IssueType.DETACHED_APP_REMOVED, "detached_addon_removed"),
],
)
async def test_ws_resolution_issue_events_legacy_compat(
coresys: CoreSys,
ha_ws_client: AsyncMock,
issue_type: str,
legacy_issue_type: str,
):
"""Test WS issue events use legacy names when SUPERVISOR_WEBSOCKET_V2_API is disabled."""
# Default: SUPERVISOR_WEBSOCKET_V2_API is disabled
coresys.resolution.add_issue(Issue(issue_type, ContextType.ADDON, reference="test"))
await asyncio.sleep(0)
ws_events = [
call.args[0]
for call in ha_ws_client.async_send_command.call_args_list
if call.args[0].get("type") == WSType.SUPERVISOR_EVENT
and call.args[0].get("data", {}).get("event") == "issue_changed"
]
assert len(ws_events) == 1
assert ws_events[0]["data"]["data"]["type"] == legacy_issue_type
# After dismissing, the issue_removed event should also use legacy name
ha_ws_client.async_send_command.reset_mock()
issue = coresys.resolution.issues[0]
coresys.resolution.dismiss_issue(issue)
await asyncio.sleep(0)
ws_events = [
call.args[0]
for call in ha_ws_client.async_send_command.call_args_list
if call.args[0].get("type") == WSType.SUPERVISOR_EVENT
and call.args[0].get("data", {}).get("event") == "issue_removed"
]
assert len(ws_events) == 1
assert ws_events[0]["data"]["data"]["type"] == legacy_issue_type
@pytest.mark.parametrize(
"issue_type",
[
IssueType.DEPRECATED_APP,
IssueType.DEPRECATED_ARCH_APP,
IssueType.DETACHED_APP_MISSING,
IssueType.DETACHED_APP_REMOVED,
],
)
async def test_ws_resolution_issue_events_v2(
coresys: CoreSys, ha_ws_client: AsyncMock, issue_type: str
):
"""Test WS issue events use new names when SUPERVISOR_WEBSOCKET_V2_API is enabled."""
coresys.config.set_feature_flag(FeatureFlag.SUPERVISOR_WEBSOCKET_V2_API, True)
coresys.resolution.add_issue(Issue(issue_type, ContextType.ADDON, reference="test"))
await asyncio.sleep(0)
ws_events = [
call.args[0]
for call in ha_ws_client.async_send_command.call_args_list
if call.args[0].get("type") == WSType.SUPERVISOR_EVENT
and call.args[0].get("data", {}).get("event") == "issue_changed"
]
assert len(ws_events) == 1
assert ws_events[0]["data"]["data"]["type"] == issue_type
# After dismissing, the issue_removed event should also use new name
ha_ws_client.async_send_command.reset_mock()
issue = coresys.resolution.issues[0]
coresys.resolution.dismiss_issue(issue)
await asyncio.sleep(0)
ws_events = [
call.args[0]
for call in ha_ws_client.async_send_command.call_args_list
if call.args[0].get("type") == WSType.SUPERVISOR_EVENT
and call.args[0].get("data", {}).get("event") == "issue_removed"
]
assert len(ws_events) == 1
assert ws_events[0]["data"]["data"]["type"] == issue_type
@pytest.mark.parametrize(
("check_slug", "legacy_slug"),
[
("app_pwned", "addon_pwned"),
("deprecated_app", "deprecated_addon"),
("deprecated_arch_app", "deprecated_arch_addon"),
("detached_app_missing", "detached_addon_missing"),
("detached_app_removed", "detached_addon_removed"),
],
)
async def test_api_resolution_check_run_v1_accepts_legacy_names(
api_client: TestClient,
check_slug: str,
legacy_slug: str,
):
"""Test v1 check run endpoint translates legacy check slugs to new ones."""
# V1 should accept legacy slug and translate it to new slug
resp = await api_client.post(f"/resolution/check/{legacy_slug}/run")
assert resp.status == 200
# V1 should also accept new slug directly
resp = await api_client.post(f"/resolution/check/{check_slug}/run")
assert resp.status == 200
@pytest.mark.parametrize(
("check_slug", "legacy_slug"),
[
("app_pwned", "addon_pwned"),
("deprecated_app", "deprecated_addon"),
("deprecated_arch_app", "deprecated_arch_addon"),
("detached_app_missing", "detached_addon_missing"),
("detached_app_removed", "detached_addon_removed"),
],
)
async def test_api_resolution_check_run_v2_accepts_new_names(
api_client_v2: TestClient, check_slug: str, legacy_slug: str
):
"""Test v2 check run endpoint accepts new check slugs and rejects legacy ones."""
# V2 should accept new slug
resp = await api_client_v2.post(f"/v2/resolution/check/{check_slug}/run")
assert resp.status == 200
# V2 should NOT accept legacy slug
resp = await api_client_v2.post(f"/v2/resolution/check/{legacy_slug}/run")
assert resp.status == HTTPStatus.NOT_FOUND
@pytest.mark.parametrize(
("check_slug", "legacy_slug"),
[
("app_pwned", "addon_pwned"),
("deprecated_app", "deprecated_addon"),
("deprecated_arch_app", "deprecated_arch_addon"),
("detached_app_missing", "detached_addon_missing"),
("detached_app_removed", "detached_addon_removed"),
],
)
async def test_api_resolution_check_options_v1_accepts_legacy_names(
api_client: TestClient,
check_slug: str,
legacy_slug: str,
):
"""Test v1 check options endpoint translates legacy check slugs to new ones."""
# V1 should accept legacy slug and translate it to new slug
resp = await api_client.post(
f"/resolution/check/{legacy_slug}/options", json={"enabled": False}
)
assert resp.status == 200
# V1 should also accept new slug directly
resp = await api_client.post(
f"/resolution/check/{check_slug}/options", json={"enabled": True}
)
assert resp.status == 200
@pytest.mark.parametrize(
("check_slug", "legacy_slug"),
[
("app_pwned", "addon_pwned"),
("deprecated_app", "deprecated_addon"),
("deprecated_arch_app", "deprecated_arch_addon"),
("detached_app_missing", "detached_addon_missing"),
("detached_app_removed", "detached_addon_removed"),
],
)
async def test_api_resolution_check_options_v2_accepts_new_names(
api_client_v2: TestClient, check_slug: str, legacy_slug: str
):
"""Test v2 check options endpoint accepts new check slugs and rejects legacy ones."""
# V2 should accept new slug
resp = await api_client_v2.post(
f"/v2/resolution/check/{check_slug}/options", json={"enabled": False}
)
assert resp.status == 200
resp = await api_client_v2.post(
f"/v2/resolution/check/{check_slug}/options", json={"enabled": True}
)
assert resp.status == 200
# V2 should NOT accept legacy slug
resp = await api_client_v2.post(
f"/v2/resolution/check/{legacy_slug}/options", json={"enabled": False}
)
assert resp.status == HTTPStatus.NOT_FOUND