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:
Joshua King
2026-09-15 17:03:53 -04:00
parent b7f7e092a0
commit 0d61ea2c88
9 changed files with 828 additions and 39 deletions
+16 -3
View File
@@ -218,12 +218,18 @@ def make_config_entry(
entry_id: str = "test_entry",
options: dict | None = None,
data: dict | None = None,
context: dict | None = None,
):
"""Create a stub config entry shaped like HA's ConfigEntry for tests."""
"""Create a stub config entry shaped like HA's ConfigEntry for tests.
``context`` mirrors HA's entry context (the flow that created the
entry carries the acting HA user under ``context["user_id"]``).
"""
entry = SimpleNamespace(
entry_id=entry_id,
options=dict(options or {}),
data=dict(data or {}),
context=dict(context or {}),
)
entry.add_update_listener = None
return entry
@@ -302,8 +308,15 @@ async def hass():
def make_entry():
"""Provide the config-entry factory as a fixture."""
def _factory(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None):
return make_config_entry(entry_id=entry_id, options=options, data=data)
def _factory(
entry_id: str = "test_entry",
options: dict | None = None,
data: dict | None = None,
context: dict | None = None,
):
return make_config_entry(
entry_id=entry_id, options=options, data=data, context=context
)
return _factory
+280
View File
@@ -0,0 +1,280 @@
"""Tests for admin_allowlist.py: the Feature 03 allowlist business layer."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
seed_setup_admin,
set_admin_ids,
)
from custom_components.nestquest.dao_children import AdminUsersDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _make_hass_mock():
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
return hass
def _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / name)
try:
await apply_migrations(database)
return await body(database)
finally:
await database.close()
return _run(_main())
return _run_test
# ---------------------------------------------------------------------------
# set_admin_ids: read/write with the fail-closed guarantee
# ---------------------------------------------------------------------------
def test_set_admin_ids_round_trip(tmp_path) -> None:
async def _body(database):
await set_admin_ids(database, ["user-owner", "user-parent"])
# Membership is the contract; AdminUsersDao.list orders by id.
assert sorted(await list_admin_ids(database)) == [
"user-owner",
"user-parent",
]
# Replace: the removed id is gone, the new one present.
await set_admin_ids(database, ["user-parent", "user-grandma"])
assert sorted(await list_admin_ids(database)) == [
"user-grandma",
"user-parent",
]
return await list_admin_ids(database)
_with_db(tmp_path, "set-round-trip.db")(_body)
def test_set_admin_ids_rejects_empty_allowlist(tmp_path) -> None:
"""Removing the last admin (or saving an empty selection) is
refused with a clear error: an empty allowlist fails closed, so
the owner must never be able to reach one through this API."""
async def _body(database):
await set_admin_ids(database, ["user-owner"])
with pytest.raises(ValueError, match="at least one admin"):
await set_admin_ids(database, [])
# The refused write left the allowlist intact.
assert await list_admin_ids(database) == ["user-owner"]
return None
_with_db(tmp_path, "set-empty-refused.db")(_body)
@pytest.mark.parametrize(
"bad", [None, "notalist", [42], [None], [""], [" "], 5]
)
def test_set_admin_ids_rejects_malformed_input(tmp_path, bad) -> None:
async def _body(database):
with pytest.raises(ValueError):
await set_admin_ids(database, bad)
assert await list_admin_ids(database) == []
return None
_with_db(tmp_path, "set-malformed.db")(_body)
def test_set_admin_ids_rejects_duplicates(tmp_path) -> None:
async def _body(database):
with pytest.raises(ValueError, match="duplicates"):
await set_admin_ids(database, ["user-a", "user-a"])
assert await list_admin_ids(database) == []
return None
_with_db(tmp_path, "set-duplicates.db")(_body)
def test_set_admin_ids_trims_and_replaces_atomically(tmp_path) -> None:
async def _body(database):
await set_admin_ids(database, [" user-a "])
assert await list_admin_ids(database) == ["user-a"]
# Sync that both adds and removes in one write.
await set_admin_ids(database, ["user-b", "user-c"])
assert await list_admin_ids(database) == ["user-b", "user-c"]
return None
_with_db(tmp_path, "set-trim.db")(_body)
# ---------------------------------------------------------------------------
# seed_setup_admin: the owner is never locked out on first setup
# ---------------------------------------------------------------------------
def test_seed_adds_context_user_on_first_setup(tmp_path) -> None:
async def _body(database):
seeded = await seed_setup_admin(
database, stored_admin_ids=None, context_user_id="owner-1"
)
assert seeded == ["owner-1"]
assert await list_admin_ids(database) == ["owner-1"]
return seeded
_with_db(tmp_path, "seed-context.db")(_body)
def test_seed_prefers_stored_entry_ids_over_context(tmp_path) -> None:
async def _body(database):
seeded = await seed_setup_admin(
database,
stored_admin_ids=["user-a", "user-b"],
context_user_id="owner-1",
)
assert seeded == ["user-a", "user-b"]
return seeded
_with_db(tmp_path, "seed-stored.db")(_body)
def test_seed_leaves_nonempty_allowlist_untouched(tmp_path) -> None:
"""Re-running setup must never resurrect a deliberately narrowed
list: only an EMPTY allowlist is seeded."""
async def _body(database):
await set_admin_ids(database, ["user-kept"])
seeded = await seed_setup_admin(
database,
stored_admin_ids=["user-a", "user-b"],
context_user_id="owner-1",
)
assert seeded == ["user-kept"]
assert await list_admin_ids(database) == ["user-kept"]
return seeded
_with_db(tmp_path, "seed-narrowed.db")(_body)
def test_seed_with_nothing_available_leaves_empty(tmp_path) -> None:
async def _body(database):
seeded = await seed_setup_admin(
database, stored_admin_ids=None, context_user_id=None
)
assert seeded == []
return seeded
_with_db(tmp_path, "seed-nothing.db")(_body)
def test_seed_rejects_malformed_candidates(tmp_path) -> None:
async def _body(database):
with pytest.raises(ValueError):
await seed_setup_admin(
database, stored_admin_ids=[42], context_user_id=None
)
with pytest.raises(ValueError):
await seed_setup_admin(
database, stored_admin_ids=None, context_user_id=" "
)
assert await list_admin_ids(database) == []
return None
_with_db(tmp_path, "seed-malformed.db")(_body)
# ---------------------------------------------------------------------------
# Setup wiring: async_setup_entry seeds automatically
# ---------------------------------------------------------------------------
async def _setup_entry(hass, entry, registry):
from tests.test_lifecycle import _wire
entry = _wire(entry, registry)
await async_setup_entry(hass, entry)
return entry
async def test_setup_seeds_allowlist_from_entry_context(
hass, make_entry
) -> None:
entry = await _setup_entry(
hass,
make_entry(context={"user_id": "ha-owner"}),
hass.registry,
)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == ["ha-owner"]
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_seeds_allowlist_from_stored_entry_data(
hass, make_entry
) -> None:
"""A lost database re-seeded from the entry's stored admin copy."""
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
entry = await _setup_entry(
hass,
make_entry(data={CONF_ADMIN_USER_IDS: ["user-a", "user-b"]}),
hass.registry,
)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == ["user-a", "user-b"]
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_without_context_leaves_allowlist_empty(
hass, make_entry
) -> None:
"""Fail closed: no context user and no stored copy means an empty
allowlist — nobody is admin until the owner configures it."""
entry = await _setup_entry(hass, make_entry(), hass.registry)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == []
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_does_not_reseed_narrowed_allowlist(
hass, make_entry
) -> None:
from custom_components.nestquest import async_unload_entry
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
entry = await _setup_entry(
hass, make_entry(context={"user_id": "ha-owner"}), hass.registry
)
database = entry.runtime_data.database
await set_admin_ids(database, ["user-parent"])
await database.close()
assert await async_unload_entry(hass, entry) is True
# Reload with the SAME context: the narrowed list survives.
entry_reloaded = await _setup_entry(
hass, make_entry(context={"user_id": "ha-owner"}), hass.registry
)
database_b = entry_reloaded.runtime_data.database
try:
assert await list_admin_ids(database_b) == ["user-parent"]
finally:
await database.close()
assert await async_unload_entry(hass, entry_reloaded) is True
+206 -4
View File
@@ -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())