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:
2026-07-19 21:08:54 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 4faa1a1f34
commit f2bf565623
28 changed files with 1321 additions and 191 deletions
+101
View File
@@ -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()