Admin allowlist storage seeded with the setup user (Feature 03, task 4)
- admin_allowlist.py: list_admin_ids / set_admin_ids (atomic sync of the allowlist inside one transaction under the connection lock; refuses empty targets so the last admin can never be removed and the allowlist can never be emptied — fail closed) and seed_setup_admin (seeds ONLY an empty allowlist: stored entry-data copy first — disaster recovery for a lost database — else the HA user who completed the config flow from the entry context; non-empty lists are never resurrected). - __init__.py: both setup paths (first setup and the reopened-record path) seed after migrations; a failed seed closes the database and re-raises through the existing error handling. - options_flow.py: the admin picker is a multi-select of EXISTING HA users (hass.auth.async_get_users), defaulted to the current database allowlist, membership-checked at the schema level and backed up on the raw-input path; empty selections are refused (no_admins), unknown ids rejected (invalid_admin); the selection is applied to the DB when the runtime record is reachable and always stored on the entry as the recovery copy. Submissions without the key (legacy raw payloads) keep the three-key behaviour untouched. - const.CONF_ADMIN_USER_IDS; strings + translations for the field and the two errors; conftest entries gain a context dict (HA's entry context carries the flow user). - Tests: 20 allowlist business tests (round-trips, refusal matrix, seed precedence and narrowed-list persistence), 5 picker flow tests, 3 setup-seeding lifecycle tests. Suite: 901 passed.
This commit is contained in:
+206
-4
@@ -14,6 +14,7 @@ from conftest import make_config_entry, make_hass, options_flow_base as _options
|
||||
from custom_components.nestquest import config_flow
|
||||
from custom_components.nestquest import options_flow
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
@@ -46,13 +47,48 @@ def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data:
|
||||
return make_config_entry(entry_id=entry_id, options=options, data=data)
|
||||
|
||||
|
||||
def _make_flow(entry) -> options_flow.NestQuestOptionsFlow:
|
||||
"""Create an options flow instance bound to a stubbed config entry."""
|
||||
def _make_flow(entry, *, users=None, database=None) -> options_flow.NestQuestOptionsFlow:
|
||||
"""Create an options flow instance bound to a stubbed config entry.
|
||||
|
||||
``users`` populates a stubbed hass.auth for the admin picker;
|
||||
``database`` wires the runtime record so the flow can read and
|
||||
write the allowlist like real HA's options flow does.
|
||||
"""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
||||
flow.hass = SimpleNamespace()
|
||||
flow.hass.auth = SimpleNamespace(
|
||||
async_get_users=AsyncMock(return_value=users or [])
|
||||
)
|
||||
if database is not None:
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
|
||||
flow.hass.data = {
|
||||
DOMAIN: {entry.entry_id: SimpleNamespace(database=database)}
|
||||
}
|
||||
return flow
|
||||
|
||||
|
||||
def _user(user_id: str, name: str):
|
||||
return SimpleNamespace(id=user_id, name=name)
|
||||
|
||||
|
||||
def _open_allowlist_db(tmp_path, name):
|
||||
"""A real migrated temp database for the flow to read and write."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
|
||||
hass = MagicMock()
|
||||
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||
database = NestQuestDatabase(hass)
|
||||
_run(database.open(tmp_path / name))
|
||||
_run(apply_migrations(database))
|
||||
return database
|
||||
|
||||
|
||||
def _schema_values(schema) -> dict:
|
||||
"""Extract the per-field default values from a real voluptuous schema."""
|
||||
return {
|
||||
@@ -161,7 +197,12 @@ def test_options_flow_falls_back_to_entry_data() -> None:
|
||||
|
||||
|
||||
def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
"""A real voluptuous validate({}) fills every field from the stored defaults."""
|
||||
"""A real voluptuous validate({}) fills every field from the stored defaults.
|
||||
|
||||
Without a runtime database (and no auth in the bare harness), the
|
||||
admin picker's default is the empty selection and its choice set is
|
||||
empty — the three legacy settings are unaffected.
|
||||
"""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
@@ -174,6 +215,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
CONF_ADMIN_USER_IDS: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -373,4 +415,164 @@ def test_options_flow_no_hardcoded_domain_or_db_filename() -> None:
|
||||
)
|
||||
assert node.value != "nestquest.db", (
|
||||
f"Hard-coded database filename in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin allowlist picker (Feature 03)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_options_flow_picker_lists_existing_users(tmp_path) -> None:
|
||||
"""The picker offers EXISTING HA users (id -> name), never free text."""
|
||||
import voluptuous as vol
|
||||
|
||||
database = _open_allowlist_db(tmp_path, "picker-users.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import set_admin_ids
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(flow.async_step_init(None))
|
||||
schema = result["data_schema"]
|
||||
marker = next(
|
||||
m for m in schema.schema
|
||||
if getattr(m, "schema", None) == CONF_ADMIN_USER_IDS
|
||||
)
|
||||
# Default is the CURRENT database allowlist.
|
||||
values = _schema_values(schema)
|
||||
assert values[CONF_ADMIN_USER_IDS] == ["u1"]
|
||||
# Both users are selectable; an unknown id is rejected by the
|
||||
# schema itself (this is what real HA submits go through).
|
||||
validated = schema(
|
||||
{
|
||||
CONF_HORIZON_DAYS: 7,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||
CONF_ADMIN_USER_IDS: ["u1", "u2"],
|
||||
}
|
||||
)
|
||||
assert validated[CONF_ADMIN_USER_IDS] == ["u1", "u2"]
|
||||
with pytest.raises(vol.Invalid):
|
||||
schema(
|
||||
{
|
||||
CONF_HORIZON_DAYS: 7,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||
CONF_ADMIN_USER_IDS: ["impostor"],
|
||||
}
|
||||
)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_submit_applies_selection_to_database(tmp_path) -> None:
|
||||
database = _open_allowlist_db(tmp_path, "picker-apply.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2"]})
|
||||
)
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"]
|
||||
# The allowlist (and the entry's recovery copy) carry the pick.
|
||||
assert await_gather(list_admin_ids(database)) == ["u2"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def await_gather(coro):
|
||||
return _run(coro)
|
||||
|
||||
|
||||
def test_options_flow_empty_selection_refused(tmp_path) -> None:
|
||||
"""Saving zero admins is refused: an empty allowlist fails closed."""
|
||||
database = _open_allowlist_db(tmp_path, "picker-empty.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: []})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_ADMIN_USER_IDS: "no_admins"}
|
||||
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_unknown_selection_refused(tmp_path) -> None:
|
||||
database = _open_allowlist_db(tmp_path, "picker-unknown.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(
|
||||
flow.async_step_init(
|
||||
{**VALID_INPUT, CONF_ADMIN_USER_IDS: ["impostor"]}
|
||||
)
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"}
|
||||
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_options_flow_submit_without_admin_key_keeps_allowlist(tmp_path) -> None:
|
||||
"""Raw callers omitting the key (pre-picker payloads) leave the
|
||||
allowlist untouched and produce the legacy three-key entry."""
|
||||
database = _open_allowlist_db(tmp_path, "picker-legacy.db")
|
||||
try:
|
||||
from custom_components.nestquest.admin_allowlist import (
|
||||
list_admin_ids,
|
||||
set_admin_ids,
|
||||
)
|
||||
|
||||
_run(set_admin_ids(database, ["u1"]))
|
||||
entry = _make_entry()
|
||||
flow = _make_flow(
|
||||
entry,
|
||||
users=[_user("u1", "Joshua")],
|
||||
database=database,
|
||||
)
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert CONF_ADMIN_USER_IDS not in result["data"]
|
||||
assert await_gather(list_admin_ids(database)) == ["u1"]
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
Reference in New Issue
Block a user