Compare commits

...

4 Commits

Author SHA1 Message Date
Mike Degatano 69e281658d Remove unnecessary async_get_hassio_entry 2026-06-26 02:46:52 +00:00
Mike Degatano b5980491a8 Refine hassio legacy migration path 2026-06-26 02:46:36 +00:00
Mike Degatano 58c4423345 Migrate legacy hassio store into config entry 2026-06-26 02:46:35 +00:00
Mike Degatano e5f7ac0cf0 Refactor hassio to persist state in config entry 2026-06-26 02:46:21 +00:00
12 changed files with 360 additions and 344 deletions
+107 -29
View File
@@ -51,16 +51,20 @@ from . import ( # noqa: F401
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState
from .addon_panel import async_setup_addon_panel
from .auth import async_setup_auth_view
from .config import HassioConfig
from .config import HassioConfigStore, StoredHassioConfig
from .config_entry import async_get_hassio_entry
from .const import (
ADDONS_COORDINATOR,
DATA_COMPONENT,
DATA_CONFIG_STORE,
DATA_HASSIO_HOST,
DATA_HASSIO_SUPERVISOR_USER,
DATA_KEY_SUPERVISOR_ISSUES,
DOMAIN,
ENTRY_DATA_USER,
MAIN_COORDINATOR,
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE,
OPTION_ADD_ON_BACKUP_RETAIN_COPIES,
OPTION_CORE_BACKUP_BEFORE_UPDATE,
STATS_COORDINATOR,
)
from .coordinator import (
@@ -163,6 +167,69 @@ def hostname_from_addon_slug(addon_slug: str) -> str:
return addon_slug.replace("_", "-")
async def _async_get_or_create_supervisor_user(
hass: HomeAssistant,
entry: ConfigEntry | None,
legacy_user_id: str | None = None,
) -> User:
"""Get or create the Supervisor system user."""
user: User | None = None
if entry is not None and (entry_user_id := entry.data.get(ENTRY_DATA_USER)):
user = await hass.auth.async_get_user(entry_user_id)
if user is None and legacy_user_id is not None:
user = await hass.auth.async_get_user(legacy_user_id)
if user is None:
user = await hass.auth.async_create_system_user(
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
)
if entry is not None:
hass.config_entries.async_update_entry(
entry,
data={**entry.data, ENTRY_DATA_USER: user.id},
)
# 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)
return user
@callback
def _async_migrate_legacy_options(
entry: ConfigEntry, legacy_data: StoredHassioConfig
) -> dict[str, bool | int]:
"""Merge legacy update options into entry options without overriding existing values."""
if not (legacy_update_config := legacy_data.get("update_config")):
return {}
option_updates: dict[str, bool | int] = {}
if OPTION_ADD_ON_BACKUP_BEFORE_UPDATE not in entry.options:
option_updates[OPTION_ADD_ON_BACKUP_BEFORE_UPDATE] = legacy_update_config[
"add_on_backup_before_update"
]
if OPTION_ADD_ON_BACKUP_RETAIN_COPIES not in entry.options:
option_updates[OPTION_ADD_ON_BACKUP_RETAIN_COPIES] = legacy_update_config[
"add_on_backup_retain_copies"
]
if OPTION_CORE_BACKUP_BEFORE_UPDATE not in entry.options:
option_updates[OPTION_CORE_BACKUP_BEFORE_UPDATE] = legacy_update_config[
"core_backup_before_update"
]
return option_updates
@callback
def _check_deprecated_setup(hass: HomeAssistant) -> None:
"""Create issues for deprecated installation types and architectures."""
@@ -234,32 +301,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host)
hass.data[DATA_HASSIO_HOST] = host
# Load the store
config_store = HassioConfig(hass)
await config_store.load()
hass.data[DATA_CONFIG_STORE] = config_store
legacy_store = HassioConfigStore(hass)
legacy_data = await legacy_store.async_load()
# Cache the Supervisor user. Create one if necessary
user: User | None = None
if (hassio_user := config_store.data.hassio_user) is not None:
user = await hass.auth.async_get_user(hassio_user)
if user:
# 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])
entry = async_get_hassio_entry(hass)
# Migrate old name
if user.name == "Hass.io":
await hass.auth.async_update_user(user, name=HASSIO_USER_NAME)
legacy_user_id: str | None = None
if legacy_data is not None:
legacy_user_id = legacy_data.get("hassio_user")
if user is None:
user = await hass.auth.async_create_system_user(
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
)
config_store.update(hassio_user=user.id)
assert user is not None
hass.data[DATA_HASSIO_SUPERVISOR_USER] = user
hass.data[DATA_HASSIO_SUPERVISOR_USER] = await _async_get_or_create_supervisor_user(
hass, entry, legacy_user_id
)
async_load_websocket_api(hass)
hass.http.register_view(HassIOView(host, websession))
@@ -270,14 +323,40 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_setup_addon_panel(hass)
frontend.async_register_built_in_panel(hass, "app")
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
if entry is None:
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
# Deprecated in 2026.8: remove this legacy store migration path after the
# deprecation window for .storage/hassio has elapsed.
legacy_store = HassioConfigStore(hass)
if (legacy_data := await legacy_store.async_load()) is not None:
option_updates = _async_migrate_legacy_options(entry, legacy_data)
if option_updates:
hass.config_entries.async_update_entry(
entry,
options={**entry.options, **option_updates},
)
await legacy_store.async_remove()
if (user := hass.data.get(DATA_HASSIO_SUPERVISOR_USER)) is not None:
if entry.data.get(ENTRY_DATA_USER) != user.id:
hass.config_entries.async_update_entry(
entry,
data={**entry.data, ENTRY_DATA_USER: user.id},
)
else:
user = await _async_get_or_create_supervisor_user(hass, entry)
hass.data[DATA_HASSIO_SUPERVISOR_USER] = user
supervisor_client = get_supervisor_client(hass)
try:
@@ -311,7 +390,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
# Get or create a refresh token for the Supervisor user
user = hass.data[DATA_HASSIO_SUPERVISOR_USER]
if user.refresh_tokens:
refresh_token = list(user.refresh_tokens.values())[0]
else:
+6 -3
View File
@@ -58,7 +58,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
from .const import DATA_CONFIG_STORE, DOMAIN, EVENT_SUPERVISOR_EVENT
from .config_entry import async_get_update_options
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT, OPTION_ADD_ON_BACKUP_RETAIN_COPIES
from .handler import get_supervisor_client
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
@@ -824,12 +825,14 @@ async def backup_addon_before_update(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return oldest backups more numerous than copies to delete."""
update_config = hass.data[DATA_CONFIG_STORE].data.update_config
retain_copies = async_get_update_options(hass)[
OPTION_ADD_ON_BACKUP_RETAIN_COPIES
]
return dict(
sorted(
backups.items(),
key=lambda backup_item: backup_item[1].date,
)[: max(len(backups) - update_config.add_on_backup_retain_copies, 0)]
)[: max(len(backups) - retain_copies, 0)]
)
try:
+27 -122
View File
@@ -1,146 +1,51 @@
"""Provide persistent configuration for the hassio integration."""
"""Legacy hassio storage helpers for migration.
from dataclasses import dataclass, replace
from typing import Required, Self, TypedDict
Deprecated in 2026.8; keep only for one-way migration into config entries.
"""
from homeassistant.core import HomeAssistant, callback
from typing import Required, TypedDict
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from .const import DOMAIN
STORE_DELAY_SAVE = 30
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 1
class HassioConfig:
"""Handle update config."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize update config."""
self.data = HassioConfigData(
hassio_user=None,
update_config=HassioUpdateConfig(),
)
self._hass = hass
self._store = HassioConfigStore(hass, self)
async def load(self) -> None:
"""Load config."""
if not (store_data := await self._store.load()):
return
self.data = HassioConfigData.from_dict(store_data)
@callback
def update(
self,
*,
hassio_user: str | UndefinedType = UNDEFINED,
update_config: HassioUpdateParametersDict | UndefinedType = UNDEFINED,
) -> None:
"""Update config."""
if hassio_user is not UNDEFINED:
self.data.hassio_user = hassio_user
if update_config is not UNDEFINED:
self.data.update_config = replace(self.data.update_config, **update_config)
self._store.save()
@dataclass(kw_only=True)
class HassioConfigData:
"""Represent loaded update config data."""
hassio_user: str | None
update_config: HassioUpdateConfig
@classmethod
def from_dict(cls, data: StoredHassioConfig) -> Self:
"""Initialize update config data from a dict."""
if update_data := data.get("update_config"):
update_config = HassioUpdateConfig(
add_on_backup_before_update=update_data["add_on_backup_before_update"],
add_on_backup_retain_copies=update_data["add_on_backup_retain_copies"],
core_backup_before_update=update_data["core_backup_before_update"],
)
else:
update_config = HassioUpdateConfig()
return cls(
hassio_user=data["hassio_user"],
update_config=update_config,
)
def to_dict(self) -> StoredHassioConfig:
"""Convert update config data to a dict."""
return StoredHassioConfig(
hassio_user=self.hassio_user,
update_config=self.update_config.to_dict(),
)
@dataclass(kw_only=True)
class HassioUpdateConfig:
"""Represent the backup retention configuration."""
add_on_backup_before_update: bool = False
add_on_backup_retain_copies: int = 1
core_backup_before_update: bool = False
def to_dict(self) -> StoredHassioUpdateConfig:
"""Convert backup retention configuration to a dict."""
return StoredHassioUpdateConfig(
add_on_backup_before_update=self.add_on_backup_before_update,
add_on_backup_retain_copies=self.add_on_backup_retain_copies,
core_backup_before_update=self.core_backup_before_update,
)
class HassioUpdateParametersDict(TypedDict, total=False):
"""Represent the parameters for update."""
class StoredHassioUpdateConfig(TypedDict):
"""Represent legacy stored update configuration."""
add_on_backup_before_update: bool
add_on_backup_retain_copies: int
core_backup_before_update: bool
class HassioConfigStore:
"""Store hassio config."""
def __init__(self, hass: HomeAssistant, config: HassioConfig) -> None:
"""Initialize the hassio config store."""
self._hass = hass
self._config = config
self._store: Store[StoredHassioConfig] = Store(
hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR
)
async def load(self) -> StoredHassioConfig | None:
"""Load the store."""
return await self._store.async_load()
@callback
def save(self) -> None:
"""Save config."""
self._store.async_delay_save(self._data_to_save, STORE_DELAY_SAVE)
@callback
def _data_to_save(self) -> StoredHassioConfig:
"""Return data to save."""
return self._config.data.to_dict()
class StoredHassioConfig(TypedDict, total=False):
"""Represent the stored hassio config."""
"""Represent the legacy hassio store payload."""
hassio_user: Required[str | None]
update_config: StoredHassioUpdateConfig
class StoredHassioUpdateConfig(TypedDict):
"""Represent the stored update config."""
class HassioConfigStore:
"""Load/remove the legacy hassio store (deprecated in 2026.8)."""
add_on_backup_before_update: bool
add_on_backup_retain_copies: int
core_backup_before_update: bool
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the legacy hassio config store."""
self._store: Store[StoredHassioConfig] = Store(
hass,
STORAGE_VERSION,
STORAGE_KEY,
minor_version=STORAGE_VERSION_MINOR,
)
async def async_load(self) -> StoredHassioConfig | None:
"""Load legacy hassio storage data."""
return await self._store.async_load()
async def async_remove(self) -> None:
"""Remove the legacy hassio storage file."""
await self._store.async_remove()
@@ -0,0 +1,48 @@
"""Helpers for hassio config entry access."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from .const import (
DEFAULT_UPDATE_OPTIONS,
DOMAIN,
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE,
OPTION_ADD_ON_BACKUP_RETAIN_COPIES,
OPTION_CORE_BACKUP_BEFORE_UPDATE,
)
@callback
def async_get_hassio_entry(hass: HomeAssistant) -> ConfigEntry | None:
"""Return the active hassio config entry if it exists."""
entries = hass.config_entries.async_entries(
DOMAIN, include_ignore=False, include_disabled=False
)
return entries[0] if entries else None
@callback
def async_get_update_options(
hass: HomeAssistant, entry: ConfigEntry | None = None
) -> dict[str, bool | int]:
"""Return hassio update options with defaults applied."""
if entry is None:
entry = async_get_hassio_entry(hass)
if entry is None:
return dict(DEFAULT_UPDATE_OPTIONS)
return {
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE: entry.options.get(
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE,
DEFAULT_UPDATE_OPTIONS[OPTION_ADD_ON_BACKUP_BEFORE_UPDATE],
),
OPTION_ADD_ON_BACKUP_RETAIN_COPIES: entry.options.get(
OPTION_ADD_ON_BACKUP_RETAIN_COPIES,
DEFAULT_UPDATE_OPTIONS[OPTION_ADD_ON_BACKUP_RETAIN_COPIES],
),
OPTION_CORE_BACKUP_BEFORE_UPDATE: entry.options.get(
OPTION_CORE_BACKUP_BEFORE_UPDATE,
DEFAULT_UPDATE_OPTIONS[OPTION_CORE_BACKUP_BEFORE_UPDATE],
),
}
+16 -2
View File
@@ -3,8 +3,14 @@
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import HASSIO_USER_NAME
from .const import DOMAIN
from .const import (
DATA_HASSIO_SUPERVISOR_USER,
DEFAULT_UPDATE_OPTIONS,
DOMAIN,
ENTRY_DATA_USER,
)
class HassIoConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -16,4 +22,12 @@ class HassIoConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
return self.async_create_entry(title="Supervisor", data={})
data: dict[str, Any] = {}
if (user := self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER)) is not None:
data[ENTRY_DATA_USER] = user.id
return self.async_create_entry(
title=HASSIO_USER_NAME,
data=data,
options=DEFAULT_UPDATE_OPTIONS,
)
+12 -2
View File
@@ -22,7 +22,6 @@ if TYPE_CHECKING:
from homeassistant.auth.models import User
from .config import HassioConfig
from .coordinator import (
HassioAddOnDataUpdateCoordinator,
HassioMainDataUpdateCoordinator,
@@ -106,7 +105,6 @@ STATS_COORDINATOR: HassKey[HassioStatsDataUpdateCoordinator] = HassKey(
DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN)
DATA_CONFIG_STORE: HassKey[HassioConfig] = HassKey("hassio_config_store")
DATA_CORE_INFO: HassKey[HomeAssistantInfo] = HassKey("hassio_core_info")
DATA_CORE_STATS = "hassio_core_stats"
DATA_HOST_INFO: HassKey[HostInfo] = HassKey("hassio_host_info")
@@ -148,6 +146,18 @@ DATA_KEY_MOUNTS = "mounts"
DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host")
DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user")
ENTRY_DATA_USER = "user"
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE = "add_on_backup_before_update"
OPTION_ADD_ON_BACKUP_RETAIN_COPIES = "add_on_backup_retain_copies"
OPTION_CORE_BACKUP_BEFORE_UPDATE = "core_backup_before_update"
DEFAULT_UPDATE_OPTIONS = {
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE: False,
OPTION_ADD_ON_BACKUP_RETAIN_COPIES: 1,
OPTION_CORE_BACKUP_BEFORE_UPDATE: False,
}
PLACEHOLDER_KEY_ADDON = "addon"
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
PLACEHOLDER_KEY_ADDON_DOCUMENTATION = "addon_documentation"
@@ -3,7 +3,7 @@
import logging
from numbers import Number
import re
from typing import Any, cast
from typing import Any
import voluptuous as vol
@@ -18,7 +18,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from .config import HassioUpdateParametersDict
from .config_entry import async_get_hassio_entry, async_get_update_options
from .const import (
ATTR_DATA,
ATTR_ENDPOINT,
@@ -30,7 +30,6 @@ from .const import (
ATTR_VERSION,
ATTR_WS_EVENT,
DATA_COMPONENT,
DATA_CONFIG_STORE,
EVENT_SUPERVISOR_EVENT,
WS_ID,
WS_TYPE,
@@ -225,9 +224,7 @@ def websocket_update_config_info(
msg: dict[str, Any],
) -> None:
"""Send the stored backup config."""
connection.send_result(
msg["id"], hass.data[DATA_CONFIG_STORE].data.update_config.to_dict()
)
connection.send_result(msg["id"], async_get_update_options(hass))
@callback
@@ -246,10 +243,23 @@ def websocket_update_config_update(
msg: dict[str, Any],
) -> None:
"""Update the stored backup config."""
entry = async_get_hassio_entry(hass)
if entry is None:
connection.send_error(
msg["id"],
code=websocket_api.ERR_UNKNOWN_ERROR,
message="Hassio config entry is not available",
)
return
changes = dict(msg)
changes.pop("id")
changes.pop("type")
hass.data[DATA_CONFIG_STORE].update(
update_config=cast(HassioUpdateParametersDict, changes)
hass.config_entries.async_update_entry(
entry,
options={
**async_get_update_options(hass, entry),
**changes,
},
)
connection.send_result(msg["id"])
+3 -7
View File
@@ -10,7 +10,7 @@ from aiohasupervisor.models import AddonsStats, AddonState, InstalledAddonComple
from aiohttp.test_utils import TestClient
import pytest
from homeassistant.components.hassio.const import DATA_CONFIG_STORE
from homeassistant.components.hassio.const import DATA_HASSIO_SUPERVISOR_USER
from homeassistant.components.hassio.handler import HassIO
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -53,9 +53,7 @@ async def hassio_client_supervisor(
hassio_stubs: None,
) -> TestClient:
"""Return an authenticated HTTP client."""
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
hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_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)
@@ -73,9 +71,7 @@ def hass_supervisor_ws_client(
"""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
hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_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)
-138
View File
@@ -1,138 +0,0 @@
"""Test websocket API."""
from collections.abc import Generator
from dataclasses import replace
from typing import Any
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import User
from homeassistant.components.hassio import HASSIO_USER_NAME
from homeassistant.components.hassio.const import DATA_CONFIG_STORE, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockUser
from tests.typing import WebSocketGenerator
MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"}
@pytest.fixture(autouse=True)
def mock_all(
supervisor_is_connected: AsyncMock,
resolution_info: AsyncMock,
addon_info: AsyncMock,
host_info: AsyncMock,
supervisor_root_info: AsyncMock,
homeassistant_info: AsyncMock,
supervisor_info: AsyncMock,
addons_list: AsyncMock,
network_info: AsyncMock,
os_info: AsyncMock,
ingress_panels: AsyncMock,
) -> None:
"""Mock all setup requests."""
supervisor_root_info.return_value = replace(
supervisor_root_info.return_value, hassos=None
)
addons_list.return_value.pop(1)
@pytest.fixture
def mock_hassio_user_id() -> Generator[None]:
"""Mock the HASSIO user ID for snapshot testing."""
original_user_init = User.__init__
def mock_user_init(self, *args, **kwargs):
with patch("homeassistant.auth.models.uuid.uuid4") as mock_uuid:
if kwargs.get("name") == HASSIO_USER_NAME:
mock_uuid.return_value = UUID(bytes=b"very_very_random", version=4)
else:
mock_uuid.return_value = uuid4()
original_user_init(self, *args, **kwargs)
with patch.object(User, "__init__", mock_user_init):
yield
@pytest.mark.usefixtures("hassio_env", "mock_hassio_user_id")
@pytest.mark.parametrize(
"storage_data",
[
{},
{
"hassio": {
"data": {
"hassio_user": "00112233445566778899aabbccddeeff",
"update_config": {
"add_on_backup_before_update": False,
"add_on_backup_retain_copies": 1,
"core_backup_before_update": False,
},
},
"key": "hassio",
"minor_version": 1,
"version": 1,
}
},
{
"hassio": {
"data": {
"hassio_user": "00112233445566778899aabbccddeeff",
"update_config": {
"add_on_backup_before_update": True,
"add_on_backup_retain_copies": 2,
"core_backup_before_update": True,
},
},
"key": "hassio",
"minor_version": 1,
"version": 1,
}
},
],
)
async def test_load_config_store(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
hass_storage: dict[str, Any],
storage_data: dict[str, dict[str, Any]],
snapshot: SnapshotAssertion,
) -> None:
"""Test loading the config store."""
hass_storage.update(storage_data)
user = MockUser(id="00112233445566778899aabbccddeeff", system_generated=True)
user.add_to_hass(hass)
await hass.auth.async_create_refresh_token(user)
await hass.auth.async_update_user(user, group_ids=[GROUP_ID_ADMIN])
with patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
await hass.async_block_till_done()
assert hass.data[DATA_CONFIG_STORE].data.to_dict() == snapshot
@pytest.mark.usefixtures("hassio_env", "mock_hassio_user_id")
async def test_save_config_store(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
supervisor_client: AsyncMock,
hass_storage: dict[str, Any],
snapshot: SnapshotAssertion,
) -> None:
"""Test saving the config store."""
with patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
await hass.async_block_till_done()
assert hass_storage[DOMAIN] == snapshot
@@ -2,7 +2,13 @@
from unittest.mock import patch
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.components.hassio import DOMAIN
from homeassistant.components.hassio.const import (
DATA_HASSIO_SUPERVISOR_USER,
DEFAULT_UPDATE_OPTIONS,
ENTRY_DATA_USER,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -25,6 +31,7 @@ async def test_config_flow(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Supervisor"
assert result["data"] == {}
assert result["options"] == DEFAULT_UPDATE_OPTIONS
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
@@ -39,3 +46,38 @@ async def test_multiple_entries(hass: HomeAssistant) -> None:
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "single_instance_allowed"
async def test_config_flow_uses_bootstrap_user(hass: HomeAssistant) -> None:
"""Test config flow stores the bootstrap supervisor user in entry data."""
user = await hass.auth.async_create_system_user(
"Supervisor", group_ids=[GROUP_ID_ADMIN]
)
hass.data[DATA_HASSIO_SUPERVISOR_USER] = user
with (
patch("homeassistant.components.hassio.async_setup", return_value=True),
patch("homeassistant.components.hassio.async_setup_entry", return_value=True),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "system"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {ENTRY_DATA_USER: user.id}
assert result["options"] == DEFAULT_UPDATE_OPTIONS
async def test_config_flow_without_bootstrap_user(hass: HomeAssistant) -> None:
"""Test config flow still creates default options without bootstrap user."""
with (
patch("homeassistant.components.hassio.async_setup", return_value=True),
patch("homeassistant.components.hassio.async_setup_entry", return_value=True),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "system"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {}
assert result["options"] == DEFAULT_UPDATE_OPTIONS
+78 -33
View File
@@ -52,10 +52,14 @@ from homeassistant.components.hassio import (
get_supervisor_stats,
hostname_from_addon_slug,
)
from homeassistant.components.hassio.config import STORAGE_KEY
from homeassistant.components.hassio.const import (
DATA_HASSIO_SUPERVISOR_USER,
DATA_KEY_SUPERVISOR_ISSUES,
ENTRY_DATA_USER,
HASSIO_MAIN_UPDATE_INTERVAL,
OPTION_ADD_ON_BACKUP_BEFORE_UPDATE,
OPTION_ADD_ON_BACKUP_RETAIN_COPIES,
OPTION_CORE_BACKUP_BEFORE_UPDATE,
REQUEST_REFRESH_DELAY,
)
from homeassistant.components.homeassistant import (
@@ -329,13 +333,10 @@ async def test_setup_api_push_api_data_server_host(
async def test_setup_api_push_api_data_default(
hass: HomeAssistant, hass_storage: dict[str, Any], supervisor_client: AsyncMock
hass: HomeAssistant, supervisor_client: AsyncMock
) -> None:
"""Test setup with API push default data."""
with (
patch.dict(os.environ, MOCK_ENVIRON),
patch("homeassistant.components.hassio.config.STORE_DELAY_SAVE", 0),
):
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}})
await hass.async_block_till_done()
@@ -347,10 +348,7 @@ async def test_setup_api_push_api_data_default(
refresh_token = (
supervisor_client.homeassistant.set_options.mock_calls[0].args[0].refresh_token
)
hassio_user = await hass.auth.async_get_user(
hass_storage[STORAGE_KEY]["data"]["hassio_user"]
)
assert hassio_user is not None
hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER]
assert hassio_user.system_generated
assert len(hassio_user.groups) == 1
assert hassio_user.groups[0].id == GROUP_ID_ADMIN
@@ -362,20 +360,18 @@ async def test_setup_api_push_api_data_default(
pytest.fail("refresh token not found")
async def test_setup_adds_admin_group_to_user(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test setup with API push default data."""
# Create user without admin
async def test_setup_adds_admin_group_to_user(hass: HomeAssistant) -> None:
"""Test setup migrates the configured user to admin."""
user = await hass.auth.async_create_system_user("Hass.io")
assert not user.is_admin
await hass.auth.async_create_refresh_token(user)
hass_storage[STORAGE_KEY] = {
"data": {"hassio_user": user.id},
"key": STORAGE_KEY,
"version": 1,
}
config_entry = MockConfigEntry(
domain=DOMAIN,
data={ENTRY_DATA_USER: user.id},
unique_id=DOMAIN,
)
config_entry.add_to_hass(hass)
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}})
@@ -384,19 +380,17 @@ async def test_setup_adds_admin_group_to_user(
assert user.is_admin
async def test_setup_migrate_user_name(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test setup with migrating the user name."""
# Create user with old name
async def test_setup_migrate_user_name(hass: HomeAssistant) -> None:
"""Test setup migrates the configured user name."""
user = await hass.auth.async_create_system_user("Hass.io")
await hass.auth.async_create_refresh_token(user)
hass_storage[STORAGE_KEY] = {
"data": {"hassio_user": user.id},
"key": STORAGE_KEY,
"version": 1,
}
config_entry = MockConfigEntry(
domain=DOMAIN,
data={ENTRY_DATA_USER: user.id},
unique_id=DOMAIN,
)
config_entry.add_to_hass(hass)
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}})
@@ -406,12 +400,18 @@ async def test_setup_migrate_user_name(
async def test_setup_api_existing_hassio_user(
hass: HomeAssistant, hass_storage: dict[str, Any], supervisor_client: AsyncMock
hass: HomeAssistant, supervisor_client: AsyncMock
) -> None:
"""Test setup with API push default data."""
"""Test setup uses the user from config entry data."""
user = await hass.auth.async_create_system_user("Hass.io test")
token = await hass.auth.async_create_refresh_token(user)
hass_storage[STORAGE_KEY] = {"version": 1, "data": {"hassio_user": user.id}}
config_entry = MockConfigEntry(
domain=DOMAIN,
data={ENTRY_DATA_USER: user.id},
unique_id=DOMAIN,
)
config_entry.add_to_hass(hass)
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}})
await hass.async_block_till_done()
@@ -423,6 +423,51 @@ async def test_setup_api_existing_hassio_user(
)
async def test_setup_migrates_legacy_hassio_store_to_config_entry(
hass: HomeAssistant,
hass_storage: dict[str, Any],
supervisor_client: AsyncMock,
) -> None:
"""Test setup migrates legacy hassio store user/options into config entry."""
user = await hass.auth.async_create_system_user("Hass.io test")
token = await hass.auth.async_create_refresh_token(user)
config_entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, unique_id=DOMAIN)
config_entry.add_to_hass(hass)
hass_storage[DOMAIN] = {
"version": 1,
"minor_version": 1,
"key": DOMAIN,
"data": {
"hassio_user": user.id,
"update_config": {
"add_on_backup_before_update": True,
"add_on_backup_retain_copies": 2,
"core_backup_before_update": True,
},
},
}
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(hass, DOMAIN, {"http": {}, "hassio": {}})
await hass.async_block_till_done()
assert result
assert DOMAIN not in hass_storage
entry = hass.config_entries.async_entries(DOMAIN, include_ignore=False)[0]
assert entry.data[ENTRY_DATA_USER] == user.id
assert entry.options[OPTION_ADD_ON_BACKUP_BEFORE_UPDATE] is True
assert entry.options[OPTION_ADD_ON_BACKUP_RETAIN_COPIES] == 2
assert entry.options[OPTION_CORE_BACKUP_BEFORE_UPDATE] is True
assert len(supervisor_client.mock_calls) == 16
supervisor_client.homeassistant.set_options.assert_called_once_with(
HomeAssistantOptions(ssl=False, port=8123, refresh_token=token.token)
)
async def test_setup_core_push_config(
hass: HomeAssistant, supervisor_client: AsyncMock
) -> None:
@@ -969,6 +969,9 @@ async def test_read_update_config(
snapshot: SnapshotAssertion,
) -> None:
"""Test read and update config."""
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
config_entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, {})
websocket_client = await hass_ws_client(hass)