Compare commits

...
Author SHA1 Message Date
Joshua King 7cde256e2a Fail-closed is_admin resolver (Feature 03, task 5)
admin_allowlist.is_admin: True only for an allowlisted HA user id;
False for non-allowlisted, unknown, empty-string, untrimmed variants,
None, non-strings (bools, lists), and an empty allowlist (nobody is
admin rather than everybody). Never raises for malformed input —
malformed means not admin; DB errors propagate for the Feature 09
gate to handle. The last-admin removal refusal (set_admin_ids) is
re-asserted as keeping the verdict sound. Tests: full verdict matrix,
empty-allowlist fail-closed, refusal persistence. Suite: 912 passed.
2026-09-15 17:35:13 -04:00
dev_agent 7a1cef1e54 Merge pull request 'Admin allowlist storage seeded with the setup user (Feature 03, task 4)' (#54) from task/288a953c-admin-allowlist into feature/4f802471-1de1-4c35-844f-eebf3bea0de5-children-registry-admin-allowlist 2026-09-15 17:33:02 -04:00
2 changed files with 71 additions and 0 deletions
@@ -91,6 +91,23 @@ async def set_admin_ids(
await dao.add(user_id, _now_stamp())
async def is_admin(
database: NestQuestDatabase, user_id: object
) -> bool:
"""Resolve an HA user ID against the allowlist — FAILS CLOSED.
True only for an allowlisted user id. Everything else is False:
a non-allowlisted id, an unknown id, a None or non-string value
(a service call with no user context), and an empty allowlist
(nobody is admin rather than everybody). The Feature 09 gate
wraps every admin-only service with this verdict; it never raises
for malformed input — malformed means not admin.
"""
if not isinstance(user_id, str) or not user_id:
return False
return await AdminUsersDao(database).exists(user_id)
async def seed_setup_admin(
database: NestQuestDatabase,
*,
+54
View File
@@ -415,3 +415,57 @@ async def test_reopened_setup_failure_closes_database(
# The reopen path opened AND closed a connection; the failure is
# the last thing observed.
assert close_calls[-1] is True
# ---------------------------------------------------------------------------
# is_admin: the fail-closed resolver (Feature 09's verdict function)
# ---------------------------------------------------------------------------
def test_is_admin_verdict_matrix(tmp_path) -> None:
async def _body(database):
from custom_components.nestquest.admin_allowlist import is_admin
await set_admin_ids(database, ["user-owner"])
assert await is_admin(database, "user-owner") is True
assert await is_admin(database, "user-other") is False
# Unknown, malformed, and absent identities all fail closed.
assert await is_admin(database, "") is False
assert await is_admin(database, " user-owner ") is False, (
"ids are stored trimmed; an untrimmed lookup is a different "
"id and must not match"
)
assert await is_admin(database, None) is False
assert await is_admin(database, 42) is False
assert await is_admin(database, ["user-owner"]) is False
assert await is_admin(database, True) is False
return None
_with_db(tmp_path, "resolver-matrix.db")(_body)
def test_is_admin_empty_allowlist_fails_closed(tmp_path) -> None:
"""An empty allowlist returns False for EVERYONE, not True."""
async def _body(database):
from custom_components.nestquest.admin_allowlist import is_admin
for candidate in ("user-owner", "", None, 1):
assert await is_admin(database, candidate) is False
return None
_with_db(tmp_path, "resolver-empty.db")(_body)
def test_is_admin_false_after_last_admin_removal_attempt(tmp_path) -> None:
"""The refusal keeps the verdict sound: a refused removal leaves
the admin in place, so is_admin still resolves True."""
async def _body(database):
from custom_components.nestquest.admin_allowlist import is_admin
await set_admin_ids(database, ["user-owner"])
with pytest.raises(ValueError, match="at least one admin"):
await set_admin_ids(database, [])
assert await is_admin(database, "user-owner") is True
return None
_with_db(tmp_path, "resolver-refusal.db")(_body)