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,141 @@
|
||||
"""Tests for RelayTV configuration and authentication flows."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.relaytv.const import CONF_API_TOKEN, CONF_BASE_URL, CONF_SERVER_NAME, DOMAIN
|
||||
|
||||
|
||||
async def test_user_flow_validates_and_stores_token(hass, aioclient_mock) -> None:
|
||||
base_url = "http://relaytv.local:8787"
|
||||
aioclient_mock.get(f"{base_url}/status", json={"state": "idle"})
|
||||
aioclient_mock.post(f"{base_url}/auth/check", json={"ok": True, "token_required": True})
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data={
|
||||
CONF_BASE_URL: base_url,
|
||||
CONF_SERVER_NAME: "Living Room",
|
||||
CONF_API_TOKEN: "testing-token",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Living Room"
|
||||
assert result["data"] == {
|
||||
CONF_BASE_URL: base_url,
|
||||
"name": "Living Room",
|
||||
CONF_API_TOKEN: "testing-token",
|
||||
}
|
||||
|
||||
|
||||
async def test_user_flow_reports_invalid_auth(hass, aioclient_mock) -> None:
|
||||
base_url = "http://relaytv.local:8787"
|
||||
aioclient_mock.get(f"{base_url}/status", json={"state": "idle"})
|
||||
aioclient_mock.post(
|
||||
f"{base_url}/auth/check",
|
||||
status=401,
|
||||
json={"detail": "api token required"},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data={
|
||||
CONF_BASE_URL: base_url,
|
||||
CONF_SERVER_NAME: "Living Room",
|
||||
CONF_API_TOKEN: "wrong",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_user_flow_rejects_credentialed_url_without_request(hass, aioclient_mock) -> None:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data={
|
||||
CONF_BASE_URL: "http://user:password@relaytv.local:8787?token=secret",
|
||||
CONF_SERVER_NAME: "Living Room",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_base_url"}
|
||||
assert not aioclient_mock.mock_calls
|
||||
|
||||
|
||||
async def test_reauth_replaces_rejected_token(hass, aioclient_mock) -> None:
|
||||
base_url = "http://relaytv.local:8787"
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Living Room",
|
||||
data={CONF_BASE_URL: base_url, "name": "Living Room", CONF_API_TOKEN: "old-token"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_REAUTH, "entry_id": entry.entry_id},
|
||||
data=entry.data,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
aioclient_mock.get(f"{base_url}/status", json={"state": "idle"})
|
||||
aioclient_mock.post(f"{base_url}/auth/check", json={"ok": True, "token_required": True})
|
||||
with patch.object(hass.config_entries, "async_reload", AsyncMock(return_value=True)):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_API_TOKEN: "new-token"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert entry.data[CONF_API_TOKEN] == "new-token"
|
||||
|
||||
|
||||
async def test_reconfigure_updates_connection_and_title(hass, aioclient_mock) -> None:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Living Room",
|
||||
unique_id="http://old-relaytv.local:8787",
|
||||
data={CONF_BASE_URL: "http://old-relaytv.local:8787", "name": "Living Room"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
base_url = "https://new-relaytv.local:8787"
|
||||
aioclient_mock.get(f"{base_url}/status", json={"state": "idle"})
|
||||
aioclient_mock.post(f"{base_url}/auth/check", json={"ok": True, "token_required": False})
|
||||
with patch.object(hass.config_entries, "async_reload", AsyncMock(return_value=True)):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_BASE_URL: base_url,
|
||||
CONF_SERVER_NAME: "Theater",
|
||||
CONF_API_TOKEN: "",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert entry.title == "Theater"
|
||||
assert entry.unique_id == base_url
|
||||
assert entry.data == {CONF_BASE_URL: base_url, "name": "Theater"}
|
||||
Reference in New Issue
Block a user