Skip to content

Commit 4ccad7f

Browse files
fix: add restart action to legacy OAuth repair (#2211)
* fix: add restart action to legacy OAuth repair * fix: match Home Assistant fixable repair schema * test: cover legacy OAuth repair lifecycle --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ceb6860 commit 4ccad7f

6 files changed

Lines changed: 196 additions & 3 deletions

File tree

custom_components/ha_mcp_tools/embedded_setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,7 @@ def _async_update_legacy_oauth_issue(hass: HomeAssistant, restart_needed: bool)
519519
hass,
520520
DOMAIN,
521521
ISSUE_LEGACY_OAUTH_RESTART,
522-
is_fixable=False,
522+
is_fixable=True,
523523
severity=ir.IssueSeverity.WARNING,
524524
translation_key=ISSUE_LEGACY_OAUTH_RESTART,
525525
)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Repair flows for the HA-MCP Custom Component."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
import voluptuous as vol
8+
from homeassistant import data_entry_flow
9+
from homeassistant.components.repairs import RepairsFlow
10+
from homeassistant.core import HomeAssistant
11+
12+
13+
class LegacyOAuthRestartRepairFlow(RepairsFlow):
14+
"""Confirm and apply a legacy OAuth change by restarting Home Assistant."""
15+
16+
async def async_step_init(
17+
self, user_input: dict[str, str] | None = None
18+
) -> data_entry_flow.FlowResult:
19+
"""Open the restart confirmation step."""
20+
return await self.async_step_confirm()
21+
22+
async def async_step_confirm(
23+
self, user_input: dict[str, str] | None = None
24+
) -> data_entry_flow.FlowResult:
25+
"""Restart Home Assistant after the user confirms the repair."""
26+
if user_input is not None:
27+
# Wait for HA to validate the config and schedule the restart. If the
28+
# service rejects the request, the exception prevents flow completion
29+
# and the repair remains registered. The next startup independently
30+
# re-evaluates whether the OAuth change is still pending.
31+
await self.hass.services.async_call(
32+
"homeassistant",
33+
"restart",
34+
{},
35+
blocking=True,
36+
)
37+
return self.async_create_entry(data={})
38+
39+
return self.async_show_form(
40+
step_id="confirm",
41+
data_schema=vol.Schema({}),
42+
)
43+
44+
45+
async def async_create_fix_flow(
46+
hass: HomeAssistant,
47+
issue_id: str,
48+
data: dict[str, Any] | None,
49+
) -> RepairsFlow:
50+
"""Create the click-to-restart flow for the legacy OAuth repair."""
51+
return LegacyOAuthRestartRepairFlow()

custom_components/ha_mcp_tools/strings.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,14 @@
108108
},
109109
"legacy_oauth_restart": {
110110
"title": "Restart Home Assistant to apply the legacy OAuth change",
111-
"description": "The legacy OAuth authentication mode registers its own /authorize and /token web endpoints, which Home Assistant can only bind or release when it fully restarts - whether you just turned this mode on, turned it off, or changed its Client ID/Secret. Restart Home Assistant (Settings - System - Restart) to apply the change; until then the previous behavior stays in effect."
111+
"fix_flow": {
112+
"step": {
113+
"confirm": {
114+
"title": "Restart Home Assistant?",
115+
"description": "Submitting this form will restart Home Assistant and apply the legacy OAuth change.\n\nClick **Submit** to restart now."
116+
}
117+
}
118+
}
112119
}
113120
},
114121
"selector": {

custom_components/ha_mcp_tools/translations/en.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,14 @@
108108
},
109109
"legacy_oauth_restart": {
110110
"title": "Restart Home Assistant to apply the legacy OAuth change",
111-
"description": "The legacy OAuth authentication mode registers its own /authorize and /token web endpoints, which Home Assistant can only bind or release when it fully restarts - whether you just turned this mode on, turned it off, or changed its Client ID/Secret. Restart Home Assistant (Settings - System - Restart) to apply the change; until then the previous behavior stays in effect."
111+
"fix_flow": {
112+
"step": {
113+
"confirm": {
114+
"title": "Restart Home Assistant?",
115+
"description": "Submitting this form will restart Home Assistant and apply the legacy OAuth change.\n\nClick **Submit** to restart now."
116+
}
117+
}
118+
}
112119
}
113120
},
114121
"selector": {
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Regression tests for the HA-MCP component's actionable restart repair.
2+
3+
Issue #2210: the legacy OAuth restart warning must offer a fix flow that
4+
restarts Home Assistant instead of only allowing the issue to be ignored.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import sys
11+
from pathlib import Path
12+
from types import ModuleType
13+
from unittest.mock import AsyncMock, MagicMock
14+
15+
import pytest
16+
17+
from ._embedded_stubs import install
18+
19+
install()
20+
21+
22+
class _RepairsFlow:
23+
"""Small HA RepairsFlow stand-in with real flow-result behavior."""
24+
25+
def async_show_form(self, *, step_id, data_schema):
26+
return {"type": "form", "step_id": step_id, "data_schema": data_schema}
27+
28+
def async_create_entry(self, *, data):
29+
return {"type": "create_entry", "data": data}
30+
31+
32+
data_entry_flow = ModuleType("homeassistant.data_entry_flow")
33+
data_entry_flow.FlowResult = dict
34+
sys.modules["homeassistant.data_entry_flow"] = data_entry_flow
35+
sys.modules["homeassistant"].data_entry_flow = data_entry_flow
36+
37+
repairs_platform = ModuleType("homeassistant.components.repairs")
38+
repairs_platform.RepairsFlow = _RepairsFlow
39+
sys.modules["homeassistant.components.repairs"] = repairs_platform
40+
41+
42+
def _load_repairs_module():
43+
from custom_components.ha_mcp_tools import repairs
44+
45+
return repairs
46+
47+
48+
async def test_legacy_oauth_fix_flow_restarts_home_assistant_blocking():
49+
"""A missing/wrong restart service call would leave the repair unresolved."""
50+
repairs = _load_repairs_module()
51+
hass = MagicMock()
52+
hass.services.async_call = AsyncMock()
53+
flow = await repairs.async_create_fix_flow(
54+
hass,
55+
"legacy_oauth_restart",
56+
None,
57+
)
58+
flow.hass = hass
59+
60+
result = await flow.async_step_confirm({})
61+
62+
hass.services.async_call.assert_awaited_once_with(
63+
"homeassistant",
64+
"restart",
65+
{},
66+
blocking=True,
67+
)
68+
assert result == {"type": "create_entry", "data": {}}
69+
70+
71+
async def test_legacy_oauth_fix_flow_prompts_before_restart():
72+
"""Opening the repair must show confirmation without restarting HA."""
73+
repairs = _load_repairs_module()
74+
hass = MagicMock()
75+
hass.services.async_call = AsyncMock()
76+
flow = await repairs.async_create_fix_flow(
77+
hass,
78+
"legacy_oauth_restart",
79+
None,
80+
)
81+
flow.hass = hass
82+
83+
result = await flow.async_step_init()
84+
85+
assert result["type"] == "form"
86+
assert result["step_id"] == "confirm"
87+
hass.services.async_call.assert_not_awaited()
88+
89+
90+
async def test_legacy_oauth_fix_flow_does_not_complete_rejected_restart():
91+
"""A rejected restart must leave the repair flow—and issue—unfinished."""
92+
repairs = _load_repairs_module()
93+
hass = MagicMock()
94+
hass.services.async_call = AsyncMock(side_effect=RuntimeError("restart rejected"))
95+
flow = await repairs.async_create_fix_flow(
96+
hass,
97+
"legacy_oauth_restart",
98+
None,
99+
)
100+
flow.hass = hass
101+
flow.async_create_entry = MagicMock()
102+
103+
with pytest.raises(RuntimeError, match="restart rejected"):
104+
await flow.async_step_confirm({})
105+
106+
flow.async_create_entry.assert_not_called()
107+
108+
109+
@pytest.mark.parametrize(
110+
"catalog_path",
111+
[
112+
"custom_components/ha_mcp_tools/strings.json",
113+
"custom_components/ha_mcp_tools/translations/en.json",
114+
],
115+
)
116+
def test_legacy_oauth_repair_catalog_has_fix_flow(catalog_path):
117+
"""Both HA English catalogs must render the actionable confirmation flow."""
118+
root = Path(__file__).parents[3]
119+
catalog = json.loads((root / catalog_path).read_text())
120+
121+
issue = catalog["issues"]["legacy_oauth_restart"]
122+
assert "description" not in issue
123+
confirm = issue["fix_flow"]["step"]["confirm"]
124+
assert confirm["title"]
125+
assert confirm["description"]

tests/src/unit/test_embedded_setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ async def test_legacy_restart_needed_files_repair(self, fake_manager, monkeypatc
172172
if esetup.ISSUE_LEGACY_OAUTH_RESTART in c.args
173173
]
174174
assert created, "legacy-OAuth restart repair was not filed"
175+
assert created[0].kwargs["is_fixable"] is True
176+
cleared = {c.args[2] for c in esetup.ir.async_delete_issue.call_args_list}
177+
assert esetup.ISSUE_LEGACY_OAUTH_RESTART not in cleared
175178
# The same restart-needed verdict must thread into the connect-URL
176179
# surfacing so the log carries the first-enable "not live" caveat --
177180
# deleting that kwarg would silently drop the caveat.

0 commit comments

Comments
 (0)