Harden RelayTV integration and prepare HACS 0.4.0 (#2)
* 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>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user