* docs: add Home Assistant 0.4.0 hardening roadmap * fix: protect media credentials and resume state * feat: authenticate RelayTV API operations * fix: make targeting and media state reliable * test: add Home Assistant integration CI * fix: validate API credentials during setup * release: prepare HACS 0.4.0 * fix: align pytest dependency pin * fix: close URL sanitizer gaps and correct player state reporting Sync the sensitive-query-key list with the RelayTV server (adds auth, exp, jwt, X-Emby-Token, X-Jellyfin-Token), filter query credentials from relative URLs instead of returning them verbatim, and preserve brackets around IPv6 literal hosts. Also report volume on RelayTV's 0-100 scale unconditionally (a raw 1 is 1%, not full volume) and give the coordinator its own position_updated_at stamp — the base DataUpdateCoordinator has no last_update_success_time, so media_position_updated_at silently fell back to now() on every read and the seek bar never extrapolated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
102 lines
3.5 KiB
Python
102 lines
3.5 KiB
Python
"""Tests for RelayTV service and sidebar targeting."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from homeassistant.core import ServiceCall
|
|
from homeassistant.exceptions import ServiceValidationError
|
|
from homeassistant.helpers import entity_registry as er
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|
|
|
from custom_components.relaytv import (
|
|
_async_set_default_sidebar_target,
|
|
_async_update_panel,
|
|
_resolve_entry_ids_for_call,
|
|
)
|
|
from custom_components.relaytv.const import (
|
|
CONF_PANEL_ENABLED,
|
|
CONF_PANEL_TARGET_ENTRY_ID,
|
|
DATA_API,
|
|
DATA_PANEL_SETTINGS,
|
|
DATA_STORE,
|
|
DOMAIN,
|
|
)
|
|
|
|
|
|
def _add_entry(hass, title: str) -> MockConfigEntry:
|
|
entry = MockConfigEntry(domain=DOMAIN, title=title, data={"base_url": f"http://{title.lower()}:8787"})
|
|
entry.add_to_hass(hass)
|
|
return entry
|
|
|
|
|
|
async def test_explicit_non_relaytv_target_never_uses_fallback(hass) -> None:
|
|
relay_entry = _add_entry(hass, "RelayTV")
|
|
other_entry = MockConfigEntry(domain="test", title="Other", data={})
|
|
other_entry.add_to_hass(hass)
|
|
registry = er.async_get(hass)
|
|
other_entity = registry.async_get_or_create(
|
|
"media_player",
|
|
"test",
|
|
"other-player",
|
|
config_entry=other_entry,
|
|
suggested_object_id="other_player",
|
|
)
|
|
hass.data[DOMAIN] = {relay_entry.entry_id: {DATA_API: MagicMock()}}
|
|
call = ServiceCall(hass, DOMAIN, "play_now", {"entity_id": other_entity.entity_id})
|
|
|
|
with pytest.raises(ServiceValidationError, match="does not contain a loaded RelayTV"):
|
|
_resolve_entry_ids_for_call(hass, call)
|
|
|
|
|
|
async def test_no_target_uses_saved_panel_target(hass) -> None:
|
|
first = _add_entry(hass, "First")
|
|
second = _add_entry(hass, "Second")
|
|
hass.data[DOMAIN] = {
|
|
first.entry_id: {DATA_API: MagicMock()},
|
|
second.entry_id: {DATA_API: MagicMock()},
|
|
DATA_PANEL_SETTINGS: {CONF_PANEL_TARGET_ENTRY_ID: second.entry_id},
|
|
}
|
|
|
|
call = ServiceCall(hass, DOMAIN, "play_now", {})
|
|
|
|
assert _resolve_entry_ids_for_call(hass, call) == [second.entry_id]
|
|
|
|
|
|
async def test_setup_does_not_overwrite_saved_panel_target(hass) -> None:
|
|
first = _add_entry(hass, "First")
|
|
second = _add_entry(hass, "Second")
|
|
store = MagicMock()
|
|
store.async_save = AsyncMock()
|
|
settings = {CONF_PANEL_ENABLED: True, CONF_PANEL_TARGET_ENTRY_ID: first.entry_id}
|
|
hass.data[DOMAIN] = {DATA_STORE: store, DATA_PANEL_SETTINGS: settings}
|
|
|
|
await _async_set_default_sidebar_target(hass, second)
|
|
|
|
assert settings[CONF_PANEL_TARGET_ENTRY_ID] == first.entry_id
|
|
store.async_save.assert_not_awaited()
|
|
|
|
|
|
async def test_panel_waits_for_saved_target_to_finish_loading(hass) -> None:
|
|
selected = _add_entry(hass, "Selected")
|
|
fallback = _add_entry(hass, "Fallback")
|
|
store = MagicMock()
|
|
store.async_save = AsyncMock()
|
|
settings = {CONF_PANEL_ENABLED: True, CONF_PANEL_TARGET_ENTRY_ID: selected.entry_id}
|
|
hass.data[DOMAIN] = {
|
|
DATA_STORE: store,
|
|
DATA_PANEL_SETTINGS: settings,
|
|
fallback.entry_id: {DATA_API: MagicMock(base_url="http://fallback:8787")},
|
|
}
|
|
|
|
with (
|
|
patch("custom_components.relaytv._async_unregister_panel") as unregister,
|
|
patch("custom_components.relaytv._register_panel") as register,
|
|
):
|
|
await _async_update_panel(hass)
|
|
|
|
assert settings[CONF_PANEL_TARGET_ENTRY_ID] == selected.entry_id
|
|
store.async_save.assert_not_awaited()
|
|
unregister.assert_not_called()
|
|
register.assert_not_called()
|