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.
This commit is contained in:
@@ -91,6 +91,23 @@ async def set_admin_ids(
|
|||||||
await dao.add(user_id, _now_stamp())
|
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(
|
async def seed_setup_admin(
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -415,3 +415,57 @@ async def test_reopened_setup_failure_closes_database(
|
|||||||
# The reopen path opened AND closed a connection; the failure is
|
# The reopen path opened AND closed a connection; the failure is
|
||||||
# the last thing observed.
|
# the last thing observed.
|
||||||
assert close_calls[-1] is True
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user