Gate every NestQuest service behind the admin permission check

permission_gate wraps all registered services at registration time:
admin-only policy reads call.context.user_id against admin_allowlist
is_admin (fail closed on no context); complete_quest stays open.
This commit is contained in:
2026-09-19 08:36:14 -04:00
parent dd7ea10936
commit 37d7b7e6c3
5 changed files with 284 additions and 84 deletions
@@ -0,0 +1,90 @@
"""Permission gate for NestQuest services (Feature 09).
A wrapper factory, applied to EVERY registered service at registration
time (no handler is exempt), reads ``call.context.user_id``, consults
the :data:`~.service_policy.SERVICE_POLICY` registry and
:func:`~.admin_allowlist.is_admin`, and raises
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
operation called by a non-admin — including a call with NO user
context, which fails closed. ``complete_quest`` (policy ``open``)
proceeds regardless of the allowlist.
Logging (later Feature 09 tasks): every denied admin-only call logs a
warning and every successful one logs a debug line, both carrying the
service, the user id (or ``none``), and a UTC timestamp.
"""
from __future__ import annotations
import datetime
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from homeassistant.exceptions import Unauthorized
from .admin_allowlist import is_admin
from .service_policy import ADMIN_ONLY
LOGGER = logging.getLogger(__name__)
ServiceHandler = Callable[[Any], Awaitable[None]]
def _context_user_id(call: Any) -> str | None:
context = getattr(call, "context", None)
user_id = getattr(context, "user_id", None) if context is not None else None
if isinstance(user_id, str) and user_id:
return user_id
return None
def _stamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def permission_gate(
service: str,
policy: str,
database_of: Callable[[Any], Any],
) -> Callable[[ServiceHandler], ServiceHandler]:
"""Wrap ``handler`` with the Feature 09 permission check.
``database_of`` resolves the live database from the call-time
runtime (the same ``find_runtime`` the handler uses), so the gate
never opens its own connection. The verdict is fail-closed:
absent allowlist row, unknown id, no user context — all not admin.
"""
def _wrap(handler: ServiceHandler) -> ServiceHandler:
if policy != ADMIN_ONLY:
return handler
async def _gated(call: Any) -> None:
user_id = _context_user_id(call)
allowed = user_id is not None and await is_admin(
database_of(call), user_id
)
if not allowed:
LOGGER.warning(
"NestQuest denied %s: caller user_id=%s at %s",
service,
user_id if user_id is not None else "none",
_stamp(),
)
raise Unauthorized(
f"User '{user_id if user_id is not None else 'none'}' "
f"is not allowed to call nestquest.{service}"
)
LOGGER.debug(
"NestQuest allowed %s: caller user_id=%s at %s",
service,
user_id,
_stamp(),
)
await handler(call)
return _gated
return _wrap
+72 -64
View File
@@ -43,8 +43,10 @@ from .const import (
) )
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .materialize import regenerate_for_child from .materialize import regenerate_for_child
from .permissions import permission_gate
from .presence import PresenceSchedule from .presence import PresenceSchedule
from .recurrence import ScheduleRule from .recurrence import ScheduleRule
from .service_policy import SERVICE_POLICY
FindRuntime = Callable[[HomeAssistant], Any] FindRuntime = Callable[[HomeAssistant], Any]
ServiceHandler = Callable[[Any], Awaitable[None]] ServiceHandler = Callable[[Any], Awaitable[None]]
@@ -521,72 +523,78 @@ def async_register_services(
find_runtime: FindRuntime, find_runtime: FindRuntime,
regenerate_handler: ServiceHandler, regenerate_handler: ServiceHandler,
) -> None: ) -> None:
"""Register every canonical service once at domain scope.""" """Register every canonical service once at domain scope.
Every service — regenerate included — passes through the Feature
09 permission gate before its handler runs; the gate reads
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
registered exempt from the policy.
"""
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
return return
hass.services.async_register( gate = permission_gate
DOMAIN, SERVICE_REGENERATE, regenerate_handler specs: list[tuple[str, ServiceHandler, object]] = [
) (SERVICE_REGENERATE, regenerate_handler, None),
hass.services.async_register( (
DOMAIN, SERVICE_COMPLETE_QUEST,
SERVICE_COMPLETE_QUEST, _complete_quest(hass, find_runtime),
_complete_quest(hass, find_runtime), SCHEMA_COMPLETE_QUEST,
schema=SCHEMA_COMPLETE_QUEST, ),
) (
hass.services.async_register( SERVICE_UNCOMPLETE_QUEST,
DOMAIN, _uncomplete_quest(hass, find_runtime),
SERVICE_UNCOMPLETE_QUEST, SCHEMA_UNCOMPLETE_QUEST,
_uncomplete_quest(hass, find_runtime), ),
schema=SCHEMA_UNCOMPLETE_QUEST, (
) SERVICE_CREATE_QUEST_DEFINITION,
hass.services.async_register( _create_quest_definition(hass, find_runtime),
DOMAIN, SCHEMA_CREATE_QUEST_DEFINITION,
SERVICE_CREATE_QUEST_DEFINITION, ),
_create_quest_definition(hass, find_runtime), (
schema=SCHEMA_CREATE_QUEST_DEFINITION, SERVICE_UPDATE_QUEST_DEFINITION,
) _update_quest_definition(hass, find_runtime),
hass.services.async_register( SCHEMA_UPDATE_QUEST_DEFINITION,
DOMAIN, ),
SERVICE_UPDATE_QUEST_DEFINITION, (
_update_quest_definition(hass, find_runtime), SERVICE_SET_QUEST_DEFINITION_ACTIVE,
schema=SCHEMA_UPDATE_QUEST_DEFINITION, _set_quest_definition_active(hass, find_runtime),
) SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
hass.services.async_register( ),
DOMAIN, (
SERVICE_SET_QUEST_DEFINITION_ACTIVE, SERVICE_SET_PRESENCE_PATTERN,
_set_quest_definition_active(hass, find_runtime), _set_presence_pattern(hass, find_runtime),
schema=SCHEMA_SET_QUEST_DEFINITION_ACTIVE, SCHEMA_SET_PRESENCE_PATTERN,
) ),
hass.services.async_register( (
DOMAIN, SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_SET_PRESENCE_PATTERN, _create_presence_override(hass, find_runtime),
_set_presence_pattern(hass, find_runtime), SCHEMA_CREATE_PRESENCE_OVERRIDE,
schema=SCHEMA_SET_PRESENCE_PATTERN, ),
) (
hass.services.async_register( SERVICE_DELETE_PRESENCE_OVERRIDE,
DOMAIN, _delete_presence_override(hass, find_runtime),
SERVICE_CREATE_PRESENCE_OVERRIDE, SCHEMA_DELETE_PRESENCE_OVERRIDE,
_create_presence_override(hass, find_runtime), ),
schema=SCHEMA_CREATE_PRESENCE_OVERRIDE, (
) SERVICE_EXPORT_HISTORY_CSV,
hass.services.async_register( _export_history_csv,
DOMAIN, SCHEMA_EXPORT_HISTORY_CSV,
SERVICE_DELETE_PRESENCE_OVERRIDE, ),
_delete_presence_override(hass, find_runtime), (
schema=SCHEMA_DELETE_PRESENCE_OVERRIDE, SERVICE_MANAGE_CHILD,
) _manage_child(hass, find_runtime),
hass.services.async_register( SCHEMA_MANAGE_CHILD,
DOMAIN, ),
SERVICE_EXPORT_HISTORY_CSV, ]
_export_history_csv,
schema=SCHEMA_EXPORT_HISTORY_CSV, def _database_of(_call: Any) -> Any:
) return _require_runtime(hass, find_runtime).database
hass.services.async_register(
DOMAIN, for service, handler, schema in specs:
SERVICE_MANAGE_CHILD, gated = gate(
_manage_child(hass, find_runtime), service, SERVICE_POLICY[service], _database_of
schema=SCHEMA_MANAGE_CHILD, )(handler)
) hass.services.async_register(DOMAIN, service, gated, schema=schema)
def async_deregister_services(hass: HomeAssistant) -> None: def async_deregister_services(hass: HomeAssistant) -> None:
+5
View File
@@ -189,8 +189,13 @@ class ConfigEntryNotReady(HomeAssistantError):
""" """
class Unauthorized(HomeAssistantError):
"""Stand-in mirroring homeassistant.exceptions.Unauthorized."""
_exceptions_mock.HomeAssistantError = HomeAssistantError _exceptions_mock.HomeAssistantError = HomeAssistantError
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
_exceptions_mock.Unauthorized = Unauthorized
import voluptuous as vol import voluptuous as vol
+21 -8
View File
@@ -9,6 +9,7 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
from custom_components.nestquest.const import ( from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME, CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS, CONF_HORIZON_DAYS,
DEFAULT_HORIZON_DAYS, DEFAULT_HORIZON_DAYS,
@@ -16,6 +17,13 @@ from custom_components.nestquest.const import (
DOMAIN as DOMAIN_CONST, DOMAIN as DOMAIN_CONST,
) )
ADMIN_ID = "admin-1"
ADMIN_CTX = {"user_id": ADMIN_ID}
def _admin_data():
return {CONF_ADMIN_USER_IDS: [ADMIN_ID]}
def _wire(entry, registry): def _wire(entry, registry):
return wire_entry_to_registry(entry, registry) return wire_entry_to_registry(entry, registry)
@@ -546,7 +554,7 @@ async def test_regenerate_service_registered_and_materializes(
from custom_components.nestquest.dao_instances import QuestInstancesDao from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(make_entry(), hass.registry) entry = _wire(make_entry(data=_admin_data()), hass.registry)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
@@ -555,7 +563,7 @@ async def test_regenerate_service_registered_and_materializes(
database = entry.runtime_data.database database = entry.runtime_data.database
child_id = await _seed_daily_child_and_definition(database, _local_today(hass)) child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
await hass.services.call(DOMAIN, SERVICE_REGENERATE) await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = _local_today(hass) today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
@@ -574,14 +582,15 @@ async def test_regenerate_service_uses_configured_horizon_days(
from custom_components.nestquest.dao_instances import QuestInstancesDao from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire( entry = _wire(
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
hass.registry,
) )
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database database = entry.runtime_data.database
child_id = await _seed_daily_child_and_definition(database, _local_today(hass)) child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
await hass.services.call(DOMAIN, SERVICE_REGENERATE) await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = _local_today(hass) today = _local_today(hass)
end = (today + datetime.timedelta(days=5)).isoformat() end = (today + datetime.timedelta(days=5)).isoformat()
@@ -622,8 +631,12 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
from custom_components.nestquest.dao_instances import QuestInstancesDao from custom_components.nestquest.dao_instances import QuestInstancesDao
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry) entry_a = _wire(
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry) make_entry(entry_id="entry_a", data=_admin_data()), hass.registry
)
entry_b = _wire(
make_entry(entry_id="entry_b", data=_admin_data()), hass.registry
)
assert await async_setup_entry(hass, entry_a) is True assert await async_setup_entry(hass, entry_a) is True
assert await async_setup_entry(hass, entry_b) is True assert await async_setup_entry(hass, entry_b) is True
@@ -634,7 +647,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
child_id = await _seed_daily_child_and_definition( child_id = await _seed_daily_child_and_definition(
database_b, _local_today(hass) database_b, _local_today(hass)
) )
await hass.services.call(DOMAIN, SERVICE_REGENERATE) await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = _local_today(hass) today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
records = await QuestInstancesDao(database_b).list_by_date_range( records = await QuestInstancesDao(database_b).list_by_date_range(
@@ -647,7 +660,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries(
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
# It still works for the remaining entry. # It still works for the remaining entry.
await hass.services.call(DOMAIN, SERVICE_REGENERATE) await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
# Unloading the final entry removes the domain-global service. # Unloading the final entry removes the domain-global service.
assert await async_unload_entry(hass, entry_b) is True assert await async_unload_entry(hass, entry_b) is True
+96 -12
View File
@@ -6,12 +6,13 @@ from pathlib import Path
import pytest import pytest
import voluptuous as vol import voluptuous as vol
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError, Unauthorized
from conftest import wire_entry_to_registry from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.const import ( from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
DOMAIN, DOMAIN,
DOMAIN_SERVICES, DOMAIN_SERVICES,
SERVICE_COMPLETE_QUEST, SERVICE_COMPLETE_QUEST,
@@ -38,6 +39,16 @@ def _wire(entry, registry):
return wire_entry_to_registry(entry, registry) return wire_entry_to_registry(entry, registry)
ADMIN_ID = "admin-1"
ADMIN_CTX = {"user_id": ADMIN_ID}
def _make_admin_entry(make_entry):
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
return make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]})
def _service_section(text: str, name: str) -> str: def _service_section(text: str, name: str) -> str:
match = re.search(rf"^{name}:\s*$", text, re.MULTILINE) match = re.search(rf"^{name}:\s*$", text, re.MULTILINE)
assert match is not None, f"services.yaml must declare {name!r}" assert match is not None, f"services.yaml must declare {name!r}"
@@ -129,12 +140,13 @@ async def test_unload_one_of_two_entries_keeps_services(
async def test_manage_child_create_persists_child(hass, make_entry) -> None: async def test_manage_child_create_persists_child(hass, make_entry) -> None:
from custom_components.nestquest.children import list_children from custom_components.nestquest.children import list_children
entry = _wire(make_entry(), hass.registry) entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
await hass.services.call( await hass.services.call(
DOMAIN, DOMAIN,
SERVICE_MANAGE_CHILD, SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"}, {"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
) )
children = await list_children(entry.runtime_data.database) children = await list_children(entry.runtime_data.database)
assert [child.display_name for child in children] == ["Ada"] assert [child.display_name for child in children] == ["Ada"]
@@ -149,12 +161,13 @@ async def test_create_and_set_quest_definition_active(
) )
from custom_components.nestquest.dao_rules import QuestDefinitionsDao from custom_components.nestquest.dao_rules import QuestDefinitionsDao
entry = _wire(make_entry(), hass.registry) entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
await hass.services.call( await hass.services.call(
DOMAIN, DOMAIN,
SERVICE_MANAGE_CHILD, SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"}, {"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
) )
child = (await list_children(entry.runtime_data.database))[0] child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call( await hass.services.call(
@@ -166,6 +179,7 @@ async def test_create_and_set_quest_definition_active(
"assignee_child_ids": [child.id], "assignee_child_ids": [child.id],
"windows": ["morning"], "windows": ["morning"],
}, },
context=ADMIN_CTX,
) )
active = await list_active_definitions(entry.runtime_data.database) active = await list_active_definitions(entry.runtime_data.database)
assert len(active) == 1 assert len(active) == 1
@@ -174,6 +188,7 @@ async def test_create_and_set_quest_definition_active(
DOMAIN, DOMAIN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE, SERVICE_SET_QUEST_DEFINITION_ACTIVE,
{"definition_id": definition_id, "is_active": False}, {"definition_id": definition_id, "is_active": False},
context=ADMIN_CTX,
) )
assert await list_active_definitions(entry.runtime_data.database) == [] assert await list_active_definitions(entry.runtime_data.database) == []
stored = await QuestDefinitionsDao(entry.runtime_data.database).get( stored = await QuestDefinitionsDao(entry.runtime_data.database).get(
@@ -193,12 +208,13 @@ async def test_complete_and_uncomplete_quest_call_business_layer(
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import QuestInstancesDao from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(make_entry(), hass.registry) entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
await hass.services.call( await hass.services.call(
DOMAIN, DOMAIN,
SERVICE_MANAGE_CHILD, SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"}, {"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
) )
child = (await list_children(entry.runtime_data.database))[0] child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call( await hass.services.call(
@@ -210,8 +226,9 @@ async def test_complete_and_uncomplete_quest_call_business_layer(
"assignee_child_ids": [child.id], "assignee_child_ids": [child.id],
"windows": ["morning"], "windows": ["morning"],
}, },
context=ADMIN_CTX,
) )
await hass.services.call(DOMAIN, SERVICE_REGENERATE) await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = datetime.date.today().isoformat() today = datetime.date.today().isoformat()
end = ( end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
@@ -240,8 +257,9 @@ async def test_complete_and_uncomplete_quest_call_business_layer(
{ {
"instance_id": instance_id, "instance_id": instance_id,
"actor": "user", "actor": "user",
"actor_user_id": "parent-1", "actor_user_id": ADMIN_ID,
}, },
context=ADMIN_CTX,
) )
assert ( assert (
await instance_state(entry.runtime_data.database, instance_id) await instance_state(entry.runtime_data.database, instance_id)
@@ -258,12 +276,13 @@ async def test_set_presence_pattern_regenerates_child_instances(
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import QuestInstancesDao from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(make_entry(), hass.registry) entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
await hass.services.call( await hass.services.call(
DOMAIN, DOMAIN,
SERVICE_MANAGE_CHILD, SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"}, {"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
) )
child = (await list_children(entry.runtime_data.database))[0] child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call( await hass.services.call(
@@ -275,8 +294,9 @@ async def test_set_presence_pattern_regenerates_child_instances(
"assignee_child_ids": [child.id], "assignee_child_ids": [child.id],
"windows": ["morning"], "windows": ["morning"],
}, },
context=ADMIN_CTX,
) )
await hass.services.call(DOMAIN, SERVICE_REGENERATE) await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = datetime.date.today().isoformat() today = datetime.date.today().isoformat()
end = ( end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
@@ -292,6 +312,7 @@ async def test_set_presence_pattern_regenerates_child_instances(
"anchor_date": today, "anchor_date": today,
"pattern": {0: []}, "pattern": {0: []},
}, },
context=ADMIN_CTX,
) )
assert await dao.list_by_date_range(child.id, today, end) == [] assert await dao.list_by_date_range(child.id, today, end) == []
@@ -306,12 +327,13 @@ async def test_presence_override_create_and_delete_regenerate(
from custom_components.nestquest.dao_instances import QuestInstancesDao from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.dao_presence import PresenceOverridesDao from custom_components.nestquest.dao_presence import PresenceOverridesDao
entry = _wire(make_entry(), hass.registry) entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
await hass.services.call( await hass.services.call(
DOMAIN, DOMAIN,
SERVICE_MANAGE_CHILD, SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"}, {"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
) )
child = (await list_children(entry.runtime_data.database))[0] child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call( await hass.services.call(
@@ -323,8 +345,9 @@ async def test_presence_override_create_and_delete_regenerate(
"assignee_child_ids": [child.id], "assignee_child_ids": [child.id],
"windows": ["morning"], "windows": ["morning"],
}, },
context=ADMIN_CTX,
) )
await hass.services.call(DOMAIN, SERVICE_REGENERATE) await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = datetime.date.today() today = datetime.date.today()
end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
dao = QuestInstancesDao(entry.runtime_data.database) dao = QuestInstancesDao(entry.runtime_data.database)
@@ -341,6 +364,7 @@ async def test_presence_override_create_and_delete_regenerate(
"end_date": end.isoformat(), "end_date": end.isoformat(),
"is_present": False, "is_present": False,
}, },
context=ADMIN_CTX,
) )
assert ( assert (
await dao.list_by_date_range( await dao.list_by_date_range(
@@ -359,6 +383,7 @@ async def test_presence_override_create_and_delete_regenerate(
DOMAIN, DOMAIN,
SERVICE_DELETE_PRESENCE_OVERRIDE, SERVICE_DELETE_PRESENCE_OVERRIDE,
{"override_id": override.id}, {"override_id": override.id},
context=ADMIN_CTX,
) )
after = await dao.list_by_date_range( after = await dao.list_by_date_range(
child.id, today.isoformat(), end.isoformat() child.id, today.isoformat(), end.isoformat()
@@ -369,10 +394,12 @@ async def test_presence_override_create_and_delete_regenerate(
async def test_export_history_csv_raises_home_assistant_error( async def test_export_history_csv_raises_home_assistant_error(
hass, make_entry hass, make_entry
) -> None: ) -> None:
entry = _wire(make_entry(), hass.registry) entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
with pytest.raises(HomeAssistantError, match="Feature 13"): with pytest.raises(HomeAssistantError, match="Feature 13"):
await hass.services.call(DOMAIN, SERVICE_EXPORT_HISTORY_CSV) await hass.services.call(
DOMAIN, SERVICE_EXPORT_HISTORY_CSV, context=ADMIN_CTX
)
async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None: async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
@@ -392,3 +419,60 @@ async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
SERVICE_MANAGE_CHILD, SERVICE_MANAGE_CHILD,
{"action": "create"}, {"action": "create"},
) )
async def test_admin_only_service_denies_non_admin(hass, make_entry) -> None:
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context={"user_id": "kiosk-panel"},
)
async def test_admin_only_service_denies_missing_user_context(
hass, make_entry
) -> None:
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
)
async def test_admin_only_service_denies_admin_on_empty_allowlist(
hass, make_entry
) -> None:
"""Even the allowlisted id is denied when the allowlist is empty."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
async def test_unauthorized_is_raised_before_handler_runs(
hass, make_entry
) -> None:
from custom_components.nestquest.children import list_children
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context={"user_id": "kiosk-panel"},
)
assert await list_children(entry.runtime_data.database) == []