From 7cde256e2afaf73d5238ad1236f25a6b65cabe5a Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:35:13 -0400 Subject: [PATCH] Fail-closed is_admin resolver (Feature 03, task 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../nestquest/admin_allowlist.py | 17 ++++++ tests/test_admin_allowlist.py | 54 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/custom_components/nestquest/admin_allowlist.py b/custom_components/nestquest/admin_allowlist.py index a672d2e..beed12c 100644 --- a/custom_components/nestquest/admin_allowlist.py +++ b/custom_components/nestquest/admin_allowlist.py @@ -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, *, diff --git a/tests/test_admin_allowlist.py b/tests/test_admin_allowlist.py index b5d15eb..5640d2d 100644 --- a/tests/test_admin_allowlist.py +++ b/tests/test_admin_allowlist.py @@ -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)