Files
RelayTV-HA/tests/test_config_flow.py
markandClaude Fable 5 f2bf565623 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>
2026-07-19 21:08:54 -05:00

142 lines
5.0 KiB
Python

"""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"}