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)