Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions homeassistant/components/hassio/websocket_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@
extra=vol.ALLOW_EXTRA,
)

# Endpoints needed for ingress can't require admin because addons can set `panel_admin: false`
# fmt: off
# Endpoints needed for ingress can't require admin because add-ons can set `panel_admin: false`
RE_ADDONS_INFO_ENDPOINT = r"/addons/[^/]+/info"
WS_ADDONS_INFO_ENDPOINT = re.compile(r"^" + RE_ADDONS_INFO_ENDPOINT + r"$")
WS_NO_ADMIN_ENDPOINTS = re.compile(
r"^(?:"
r"|/ingress/(session|validate_session)"
r"|/addons/[^/]+/info"
r"/ingress/(session|validate_session)"
f"|{RE_ADDONS_INFO_ENDPOINT}"
r")$"
)
# fmt: on

_LOGGER: logging.Logger = logging.getLogger(__package__)

Expand Down Expand Up @@ -92,6 +92,7 @@ def forward_messages(data: dict[str, str]) -> None:


@callback
@websocket_api.ws_require_user(only_supervisor=True)
@websocket_api.websocket_command(
Comment on lines 94 to 96
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Align the access restriction for supervisor/event with the PR’s stated intent (admin-only) or update the PR title/description to reflect that this now requires the special Supervisor user.

@ws_require_user(only_supervisor=True) blocks even regular admin users and returns only_supervisor, which is stricter than “require admin” and changes the documented breaking behavior.

Copilot uses AI. Check for mistakes.
{
vol.Required(WS_TYPE): WS_TYPE_EVENT,
Expand Down Expand Up @@ -150,7 +151,12 @@ async def websocket_supervisor_api(
msg[WS_ID], code=websocket_api.ERR_UNKNOWN_ERROR, message=str(err)
)
else:
connection.send_result(msg[WS_ID], result.get(ATTR_DATA, {}))
data = result.get(ATTR_DATA, {})
# Remove options from add-on info for non-admin users, as options can contain
# sensitive information and the frontend does not require it for ingress.
if not connection.user.is_admin and WS_ADDONS_INFO_ENDPOINT.match(command):
data.pop("options", None)
connection.send_result(msg[WS_ID], data)


@websocket_api.require_admin
Expand Down
21 changes: 20 additions & 1 deletion tests/components/hassio/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from . import SUPERVISOR_TOKEN

from tests.typing import ClientSessionGenerator
from tests.typing import ClientSessionGenerator, WebSocketGenerator


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -65,6 +65,25 @@ async def hassio_client_supervisor(
)


@pytest.fixture
def hass_supervisor_ws_client(
hass_ws_client: WebSocketGenerator,
hass: HomeAssistant,
) -> WebSocketGenerator:
"""Return a websocket client authenticated as the Supervisor user."""

async def create_client() -> WebSocketGenerator:
hassio_user_id = hass.data[DATA_CONFIG_STORE].data.hassio_user
hassio_user = await hass.auth.async_get_user(hassio_user_id)
assert hassio_user
assert hassio_user.refresh_tokens
refresh_token = next(iter(hassio_user.refresh_tokens.values()))
access_token = hass.auth.async_create_access_token(refresh_token)
Comment on lines +80 to +81
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use the existing hass_supervisor_access_token fixture instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a drop-in replacement. I'm not entirely sure why at the moment. I'm guessing it has to do with this code:

config_store = HassioConfig(hass)
await config_store.load()
hass.data[DATA_CONFIG_STORE] = config_store
refresh_token = None
if (hassio_user := config_store.data.hassio_user) is not None:
user = await hass.auth.async_get_user(hassio_user)
if user and user.refresh_tokens:
refresh_token = list(user.refresh_tokens.values())[0]
# Migrate old Hass.io users to be admin.
if not user.is_admin:
await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN])
# Migrate old name
if user.name == "Hass.io":
await hass.auth.async_update_user(user, name=HASSIO_USER_NAME)
if refresh_token is None:
user = await hass.auth.async_create_system_user(
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
)
refresh_token = await hass.auth.async_create_refresh_token(user)
config_store.update(hassio_user=user.id)

Currently this fixture has zero usage throughout the hassio tests. The only places it is used is in tests of other integrations mocking Supervisor. I'm wondering if the user it makes for the access token gets booted if the hassio integration is actually loaded by this config_store code.

I can probably fix it but this feels like a separate problem now. Given the context of the PR from the audit I think it would be better to just merge what we have that works and take a look at what's going on with the loading of the Supervisor user as a follow-up effort.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah it relies on the hass_supervisor_user fixture, which uses a mock user. Your fixture uses the real Supervisor user. Ok then, let's go with what we have then.

return await hass_ws_client(hass, access_token=access_token)
Comment on lines +75 to +82
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix the return type annotation of create_client to the actual websocket client type (not WebSocketGenerator).

WebSocketGenerator is a callable that creates a websocket client; create_client itself returns the created websocket client response, so the current annotation is incorrect and can break type checking/autocomplete.

Copilot uses AI. Check for mistakes.

return create_client


@pytest.fixture
async def hassio_handler(hass: HomeAssistant) -> AsyncGenerator[HassIO]:
"""Create mock hassio handler."""
Expand Down
56 changes: 28 additions & 28 deletions tests/components/hassio/test_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -934,13 +934,13 @@ async def test_agent_delete_with_error(
)
async def test_agents_notify_on_mount_added_removed(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
event_data: dict[str, Any],
mount_info_calls: int,
) -> None:
"""Test the listener is called when mounts are added or removed."""
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
assert supervisor_client.mounts.info.call_count == 1
assert supervisor_client.mounts.info.call_args[0] == ()
supervisor_client.mounts.info.reset_mock()
Expand Down Expand Up @@ -1019,14 +1019,14 @@ async def test_agents_notify_on_mount_added_removed(
)
async def test_reader_writer_create(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
supervisor_client: AsyncMock,
extra_generate_options: dict[str, Any],
expected_supervisor_options: supervisor_backups.PartialBackupOptions,
) -> None:
"""Test generating a backup."""
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
freezer.move_to("2025-01-30 13:42:12.345678")
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
Expand Down Expand Up @@ -1112,7 +1112,7 @@ async def test_reader_writer_create(
)
async def test_reader_writer_create_addon_folder_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
supervisor_client: AsyncMock,
addon_info_side_effect: list[Any],
Expand All @@ -1121,7 +1121,7 @@ async def test_reader_writer_create_addon_folder_error(
addon_info_side_effect[0].name = "Advanced SSH & Web Terminal"
assert dt.datetime.__name__ == "HAFakeDatetime"
assert dt.HAFakeDatetime.__name__ == "HAFakeDatetime"
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
freezer.move_to("2025-01-30 13:42:12.345678")
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
Expand Down Expand Up @@ -1242,12 +1242,12 @@ async def test_reader_writer_create_addon_folder_error(
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_create_report_progress(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
supervisor_client: AsyncMock,
) -> None:
"""Test generating a backup."""
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
freezer.move_to("2025-01-30 13:42:12.345678")
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
Expand Down Expand Up @@ -1573,7 +1573,7 @@ async def test_reader_writer_create_job_done(
)
async def test_reader_writer_create_per_agent_encryption(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
supervisor_client: AsyncMock,
commands: list[dict[str, Any]],
Expand All @@ -1585,7 +1585,7 @@ async def test_reader_writer_create_per_agent_encryption(
upload_locations: list[str | None],
) -> None:
"""Test generating a backup."""
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
freezer.move_to("2025-01-30 13:42:12.345678")
mounts = MountsInfo(
default_backup_mount=None,
Expand Down Expand Up @@ -1809,12 +1809,12 @@ async def test_reader_writer_create_partial_backup_error(
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_create_missing_reference_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
supervisor_event: dict[str, Any],
) -> None:
"""Test missing reference error when generating a backup."""
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE

Expand Down Expand Up @@ -1914,7 +1914,7 @@ async def test_reader_writer_create_missing_reference_error(
)
async def test_reader_writer_create_download_remove_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
exception: Exception,
method: str,
Expand All @@ -1923,7 +1923,7 @@ async def test_reader_writer_create_download_remove_error(
expected_events_before_failed: list[dict[str, str]],
) -> None:
"""Test download and remove error when generating a backup."""
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
Expand Down Expand Up @@ -2011,12 +2011,12 @@ async def test_reader_writer_create_download_remove_error(
@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")])
async def test_reader_writer_create_info_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
exception: Exception,
) -> None:
"""Test backup info error when generating a backup."""
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.side_effect = exception
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
Expand Down Expand Up @@ -2087,12 +2087,12 @@ async def test_reader_writer_create_info_error(
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_create_remote_backup(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
supervisor_client: AsyncMock,
) -> None:
"""Test generating a backup which will be uploaded to a remote agent."""
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
freezer.move_to("2025-01-30 13:42:12.345678")
supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5
Expand Down Expand Up @@ -2301,13 +2301,13 @@ async def test_agent_receive_remote_backup(
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_restore(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
get_job_result: supervisor_jobs.Job,
supervisor_events: list[dict[str, Any]],
) -> None:
"""Test restoring a backup."""
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.list.return_value = [TEST_BACKUP]
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
Expand Down Expand Up @@ -2368,11 +2368,11 @@ async def test_reader_writer_restore(
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_restore_remote_backup(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test restoring a backup from a remote agent."""
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.list.return_value = [TEST_BACKUP_5]
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5
Expand Down Expand Up @@ -2465,11 +2465,11 @@ async def test_reader_writer_restore_remote_backup(
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_restore_report_progress(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test restoring a backup."""
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.list.return_value = [TEST_BACKUP]
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
Expand Down Expand Up @@ -2646,11 +2646,11 @@ async def test_reader_writer_restore_error(
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
async def test_reader_writer_restore_late_error(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test restoring a backup with error."""
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID)
supervisor_client.backups.list.return_value = [TEST_BACKUP]
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
Expand Down Expand Up @@ -2838,7 +2838,7 @@ async def test_restore_progress_after_restart(
@pytest.mark.usefixtures("hassio_client")
async def test_restore_progress_after_restart_report_progress(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
) -> None:
"""Test restore backup progress after restart."""
Expand All @@ -2848,7 +2848,7 @@ async def test_restore_progress_after_restart_report_progress(
with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}):
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})

client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()

await client.send_json_auto_id({"type": "backup/subscribe_events"})
response = await client.receive_json()
Expand Down
4 changes: 2 additions & 2 deletions tests/components/hassio/test_binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ async def test_mount_refresh_after_issue(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
supervisor_client: AsyncMock,
hass_ws_client: WebSocketGenerator,
hass_supervisor_ws_client: WebSocketGenerator,
) -> None:
"""Test hassio mount state is refreshed after an issue was send by the supervisor."""
# Add a mount.
Expand Down Expand Up @@ -255,7 +255,7 @@ async def test_mount_refresh_after_issue(

# Change mount state to failed, issue a repair, and verify entity's state.
mock_mounts[0] = replace(mock_mounts[0], state=MountState.FAILED)
client = await hass_ws_client(hass)
client = await hass_supervisor_ws_client()
issue_uuid = uuid4().hex
await client.send_json(
{
Expand Down
Loading
Loading