diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 8cc9b79..b0edf5b 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -24,11 +24,11 @@ from .const import ( DEFAULT_HORIZON_DAYS, DOMAIN, LOGGER, - SERVICE_REGENERATE, ) from .db import NestQuestDatabase from .materialize import materialize as _materialize_run from .migrations import apply_migrations +from .services import async_deregister_services, async_register_services from .store import async_get_db_path from .admin_allowlist import seed_setup_admin @@ -356,21 +356,8 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None: return None -def _register_regenerate_service(hass: HomeAssistant) -> None: - """Register ``nestquest.regenerate`` once at domain scope. - - The service triggers a full on-demand regeneration over the rolling - horizon, running the SAME idempotent materialize path - (:func:`_run_horizon_materialization`) as the startup backfill and the - daily rollover listener. It is DOMAIN-global — registered exactly once - (guarded by ``has_service``) and NOT bound to any config entry — so the - handler resolves the currently-live database at call time and a name - collision or duplicate setup is impossible. Removal is handled by the - unload path only once the LAST entry is removed (see - :func:`_deregister_regenerate_service`). - """ - if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): - return +def _make_regenerate_handler(hass: HomeAssistant): + """Return the Feature 07 ``regenerate`` handler closed over ``hass``.""" async def _regenerate(_call: Any) -> None: runtime_data = _find_live_runtime_data(hass) @@ -385,13 +372,29 @@ def _register_regenerate_service(hass: HomeAssistant) -> None: hass, runtime_data.database, horizon_days ) - hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate) + return _regenerate -def _deregister_regenerate_service(hass: HomeAssistant) -> None: - """Remove ``nestquest.regenerate`` when the last entry unloads.""" - if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): - hass.services.async_remove(DOMAIN, SERVICE_REGENERATE) +def _register_services(hass: HomeAssistant) -> None: + """Register domain-global NestQuest services once. + + Services are DOMAIN-global — registered exactly once (guarded by + ``has_service`` on regenerate) and NOT bound to any config entry — + so each handler resolves the currently-live database at call time + and a name collision or duplicate setup is impossible. Removal is + handled by the unload path only once the LAST entry is removed + (see :func:`_deregister_services`). + """ + async_register_services( + hass, + find_runtime=_find_live_runtime_data, + regenerate_handler=_make_regenerate_handler(hass), + ) + + +def _deregister_services(hass: HomeAssistant) -> None: + """Remove domain-global NestQuest services when the last entry unloads.""" + async_deregister_services(hass) async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: @@ -489,7 +492,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await _run_horizon_materialization( hass, database, _configured_horizon_days(dict(entry.options)) ) - _register_regenerate_service(hass) + _register_services(hass) except BaseException: # A failure anywhere after the database is open must not leak the # listeners already registered against that (now-closed) database: @@ -562,11 +565,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: database = getattr(runtime_data, "database", None) if database is not None: await database.close() - # The ``regenerate`` service is DOMAIN-global: it is torn down only - # once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty), - # never when a sibling entry is still loaded. + # Domain-global services are torn down only once the FINAL entry + # unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling + # entry is still loaded. if is_last: - _deregister_regenerate_service(hass) + _deregister_services(hass) if getattr(entry, "runtime_data", None) is not None: entry.runtime_data = None if unload_error is not None: diff --git a/custom_components/nestquest/const.py b/custom_components/nestquest/const.py index ce21568..6238252 100644 --- a/custom_components/nestquest/const.py +++ b/custom_components/nestquest/const.py @@ -9,9 +9,34 @@ LOGGER = logging.getLogger(LOGGER_NAME) PLATFORMS: list[str] = [] -#: Service name for the on-demand regeneration service (``nestquest.regenerate``). +#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2 +#: plus the existing Feature 07 regenerate service). +SERVICE_COMPLETE_QUEST = "complete_quest" +SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest" +SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition" +SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition" +SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active" +SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern" +SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override" +SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override" +SERVICE_EXPORT_HISTORY_CSV = "export_history_csv" +SERVICE_MANAGE_CHILD = "manage_child" SERVICE_REGENERATE = "regenerate" +DOMAIN_SERVICES: tuple[str, ...] = ( + SERVICE_COMPLETE_QUEST, + SERVICE_UNCOMPLETE_QUEST, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_UPDATE_QUEST_DEFINITION, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, +) + SQLITE_DB_FILENAME = "nestquest.db" # Configuration keys and default values diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py index cf1707a..c273287 100644 --- a/custom_components/nestquest/dao_presence.py +++ b/custom_components/nestquest/dao_presence.py @@ -270,6 +270,15 @@ class PresenceOverridesDao: ) return [_override_from_row(row) for row in rows] + async def get(self, override_id: int) -> PresenceOverrideRecord | None: + """Return one override by id, or None when it does not exist.""" + row = await self._database.fetch_one( + f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides " + "WHERE id = ?", + (override_id,), + ) + return _override_from_row(row) if row is not None else None + async def delete(self, override_id: int) -> bool: """Remove one override by id; True if a row was removed.""" result = await self._database.execute( diff --git a/custom_components/nestquest/permissions.py b/custom_components/nestquest/permissions.py new file mode 100644 index 0000000..5f6df6b --- /dev/null +++ b/custom_components/nestquest/permissions.py @@ -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 \ No newline at end of file diff --git a/custom_components/nestquest/service_policy.py b/custom_components/nestquest/service_policy.py new file mode 100644 index 0000000..823bed4 --- /dev/null +++ b/custom_components/nestquest/service_policy.py @@ -0,0 +1,46 @@ +"""Explicit admin-only versus open service registry (Feature 09). + +One mapping, one file: every registered ``nestquest`` service name is +either ``open`` or ``admin_only``. The permission decorator (later +Feature 09 tasks) reads this table; it does not take per-handler +policy arguments. +""" +from __future__ import annotations + +from .const import ( + DOMAIN_SERVICES, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_UNCOMPLETE_QUEST, + SERVICE_UPDATE_QUEST_DEFINITION, +) + +OPEN = "open" +ADMIN_ONLY = "admin_only" + +SERVICE_POLICY: dict[str, str] = { + SERVICE_COMPLETE_QUEST: OPEN, + SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY, + SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY, + SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY, + SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY, + SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY, + SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY, + SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY, + SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY, + SERVICE_MANAGE_CHILD: ADMIN_ONLY, + SERVICE_REGENERATE: ADMIN_ONLY, +} + +if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES): + raise RuntimeError( + "SERVICE_POLICY must name exactly the registered nestquest services, " + f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}" + ) diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py new file mode 100644 index 0000000..9c6d765 --- /dev/null +++ b/custom_components/nestquest/services.py @@ -0,0 +1,604 @@ +"""Domain-global NestQuest service schemas and handlers (Feature 09). + +Registration is domain-scoped and happens once (see +:func:`async_register_services`); handlers resolve a live database at +call time through the injected ``find_runtime`` callable. Permission +checks are Feature 09 later tasks — this module only validates payload +shape and forwards to the Feature 03/06/08 business layers (and the +presence DAOs). ``export_history_csv`` is registered with a schema but +raises until Feature 13. ``regenerate`` is supplied by the caller so +the Feature 07 handler stays the one in ``__init__``. +""" +from __future__ import annotations + +import datetime +from collections.abc import Awaitable, Callable +from typing import Any +from zoneinfo import ZoneInfo + +import voluptuous as vol +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import children as children_layer +from . import completion as completion_layer +from . import quest_definitions as quest_definitions_layer +from .const import ( + CONF_HORIZON_DAYS, + DEFAULT_HORIZON_DAYS, + DOMAIN, + DOMAIN_SERVICES, + QUEST_WINDOWS, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_UNCOMPLETE_QUEST, + SERVICE_UPDATE_QUEST_DEFINITION, +) +from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao +from .materialize import regenerate_for_child +from .permissions import permission_gate +from .presence import PresenceSchedule +from .recurrence import ScheduleRule +from .service_policy import SERVICE_POLICY + +FindRuntime = Callable[[HomeAssistant], Any] +ServiceHandler = Callable[[Any], Awaitable[None]] + +ACTOR_PANEL = "panel" +ACTOR_USER = "user" +MANAGE_CHILD_CREATE = "create" +MANAGE_CHILD_EDIT = "edit" +MANAGE_CHILD_SET_ACTIVE = "set_active" +MANAGE_CHILD_REORDER = "reorder" + + +def _strict_int(value: object) -> int: + """Reject bools and non-ints; voluptuous ``int`` would accept True.""" + if isinstance(value, bool) or not isinstance(value, int): + raise vol.Invalid(f"expected int, got {value!r}") + return value + + +def _strict_bool(value: object) -> bool: + """Reject ints and other truthy values; require a real bool.""" + if type(value) is not bool: + raise vol.Invalid(f"expected bool, got {value!r}") + return value + + +def _weekday_list(value: object) -> list[int]: + if type(value) is not list: + raise vol.Invalid(f"expected a list of weekday ints, got {value!r}") + return [_strict_int(item) for item in value] + + +def _presence_pattern(value: object) -> dict[int, list[int]]: + if not isinstance(value, dict): + raise vol.Invalid(f"pattern must be a mapping, got {value!r}") + pattern: dict[int, list[int]] = {} + for week, days in value.items(): + if isinstance(week, str): + try: + week_index = int(week) + except ValueError as err: + raise vol.Invalid( + f"pattern week index must be an integer, got {week!r}" + ) from err + if str(week_index) != week: + raise vol.Invalid( + f"pattern week index must be an integer, got {week!r}" + ) + else: + week_index = _strict_int(week) + pattern[week_index] = _weekday_list(days) + return pattern + + +def _window_entry(value: object) -> str | tuple[str, str | None]: + if isinstance(value, str): + if value not in QUEST_WINDOWS: + raise vol.Invalid(f"unknown window {value!r}") + return value + if isinstance(value, dict): + parsed = vol.Schema( + { + vol.Required("window"): vol.In(QUEST_WINDOWS), + vol.Optional("due_time"): str, + } + )(value) + due_time = parsed.get("due_time") + if due_time is None: + return parsed["window"] + return (parsed["window"], due_time) + raise vol.Invalid( + f"windows entry must be a window name or object, got {value!r}" + ) + + +def _windows(value: object) -> list[str | tuple[str, str | None]]: + if type(value) is not list: + raise vol.Invalid(f"windows must be a list, got {value!r}") + if not value: + raise vol.Invalid("windows must not be empty") + return [_window_entry(entry) for entry in value] + + +def _int_list(value: object) -> list[int]: + if type(value) is not list: + raise vol.Invalid(f"expected a list of ints, got {value!r}") + return [_strict_int(item) for item in value] + + +def _rule_dict(value: object) -> dict: + if not isinstance(value, dict): + raise vol.Invalid(f"rule must be a dict, got {value!r}") + return value + + +SCHEMA_COMPLETE_QUEST = vol.Schema( + { + vol.Required("instance_id"): _strict_int, + vol.Required("actor"): vol.In([ACTOR_PANEL, ACTOR_USER]), + vol.Optional("actor_child_id"): _strict_int, + vol.Optional("actor_user_id"): str, + } +) + +SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST + +SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema( + { + vol.Required("title"): str, + vol.Required("rule"): _rule_dict, + vol.Required("assignee_child_ids"): vol.All( + _int_list, vol.Length(min=1) + ), + vol.Required("windows"): _windows, + vol.Optional("description"): str, + vol.Optional("icon"): str, + } +) + +SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema( + { + vol.Required("definition_id"): _strict_int, + vol.Optional("title"): str, + vol.Optional("description"): vol.Any(str, None), + vol.Optional("icon"): vol.Any(str, None), + vol.Optional("rule"): _rule_dict, + vol.Optional("windows"): _windows, + } +) + +SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema( + { + vol.Required("definition_id"): _strict_int, + vol.Required("is_active"): _strict_bool, + } +) + +SCHEMA_SET_PRESENCE_PATTERN = vol.Schema( + { + vol.Required("child_id"): _strict_int, + vol.Required("cycle_length_weeks"): _strict_int, + vol.Required("anchor_date"): str, + vol.Required("pattern"): _presence_pattern, + } +) + +SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema( + { + vol.Required("child_id"): _strict_int, + vol.Required("start_date"): str, + vol.Required("end_date"): str, + vol.Required("is_present"): _strict_bool, + vol.Optional("note"): str, + } +) + +SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema( + { + vol.Required("override_id"): _strict_int, + } +) + +SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({}) + +SCHEMA_MANAGE_CHILD = vol.Schema( + { + vol.Required("action"): vol.In( + [ + MANAGE_CHILD_CREATE, + MANAGE_CHILD_EDIT, + MANAGE_CHILD_SET_ACTIVE, + MANAGE_CHILD_REORDER, + ] + ), + vol.Optional("child_id"): _strict_int, + vol.Optional("display_name"): str, + vol.Optional("colour"): str, + vol.Optional("avatar_ref"): str, + vol.Optional("sort_order"): _strict_int, + vol.Optional("is_active"): _strict_bool, + vol.Optional("ordered_ids"): _int_list, + } +) + + +def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime): + runtime = find_runtime(hass) + if runtime is None: + raise HomeAssistantError( + "NestQuest has no live database; cannot run this service" + ) + return runtime + + +def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]: + time_zone = ZoneInfo(hass.config.time_zone) + today = datetime.datetime.now(time_zone).date() + value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS) + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + horizon_days = DEFAULT_HORIZON_DAYS + else: + horizon_days = value + return {"today": today, "horizon_days": horizon_days} + + +def _actor_kwargs(call: Any) -> dict[str, Any]: + actor = call.data["actor"] + if actor == ACTOR_PANEL: + actor_child_id = call.data.get("actor_child_id") + if actor_child_id is None: + raise HomeAssistantError( + "actor_child_id is required when actor is panel" + ) + return { + "actor_source": ACTOR_PANEL, + "actor_child_id": actor_child_id, + "actor_user_id": None, + } + context = getattr(call, "context", None) + user_id = getattr(context, "user_id", None) if context is not None else None + if not user_id: + user_id = call.data.get("actor_user_id") + return { + "actor_source": ACTOR_USER, + "actor_user_id": user_id, + "actor_child_id": None, + } + + +def _with_errors(handler: ServiceHandler) -> ServiceHandler: + async def _wrapped(call: Any) -> None: + try: + await handler(call) + except HomeAssistantError: + raise + except ValueError as err: + raise HomeAssistantError(str(err)) from err + + return _wrapped + + +def _complete_quest( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_runtime(hass, find_runtime).database + await completion_layer.complete_instance( + database, + call.data["instance_id"], + **_actor_kwargs(call), + ) + + return _with_errors(_handler) + + +def _uncomplete_quest( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_runtime(hass, find_runtime).database + await completion_layer.uncomplete_instance( + database, + call.data["instance_id"], + **_actor_kwargs(call), + ) + + return _with_errors(_handler) + + +def _create_quest_definition( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_runtime(hass, find_runtime).database + data = call.data + await quest_definitions_layer.create_quest_definition( + database, + data["title"], + ScheduleRule.from_dict(data["rule"]), + data["assignee_child_ids"], + data["windows"], + description=data.get("description"), + icon=data.get("icon"), + ) + + return _with_errors(_handler) + + +def _update_quest_definition( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + runtime = _require_runtime(hass, find_runtime) + data = call.data + kwargs: dict[str, Any] = _generation_kwargs(hass, runtime) + if "title" in data: + kwargs["title"] = data["title"] + if "description" in data: + kwargs["description"] = data["description"] + if "icon" in data: + kwargs["icon"] = data["icon"] + if "rule" in data: + kwargs["rule"] = ScheduleRule.from_dict(data["rule"]) + if "windows" in data: + kwargs["windows"] = data["windows"] + await quest_definitions_layer.edit_quest_definition( + runtime.database, data["definition_id"], **kwargs + ) + + return _with_errors(_handler) + + +def _set_quest_definition_active( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + runtime = _require_runtime(hass, find_runtime) + await quest_definitions_layer.set_quest_definition_active( + runtime.database, + call.data["definition_id"], + call.data["is_active"], + **_generation_kwargs(hass, runtime), + ) + + return _with_errors(_handler) + + +def _set_presence_pattern( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + runtime = _require_runtime(hass, find_runtime) + data = call.data + schedule = PresenceSchedule( + data["child_id"], + data["cycle_length_weeks"], + data["anchor_date"], + data["pattern"], + ) + await PresenceSchedulesDao(runtime.database).upsert_by_child( + schedule.child_id, + schedule.cycle_length_weeks, + schedule.anchor_date.isoformat(), + schedule.encode(), + ) + await regenerate_for_child( + runtime.database, + schedule.child_id, + **_generation_kwargs(hass, runtime), + ) + + return _with_errors(_handler) + + +def _create_presence_override( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + runtime = _require_runtime(hass, find_runtime) + data = call.data + await PresenceOverridesDao(runtime.database).create( + data["child_id"], + data["start_date"], + data["end_date"], + data["is_present"], + note=data.get("note"), + ) + await regenerate_for_child( + runtime.database, + data["child_id"], + **_generation_kwargs(hass, runtime), + ) + + return _with_errors(_handler) + + +def _delete_presence_override( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + runtime = _require_runtime(hass, find_runtime) + dao = PresenceOverridesDao(runtime.database) + override_id = call.data["override_id"] + override = await dao.get(override_id) + if override is None: + raise HomeAssistantError( + f"override_id: presence override {override_id} does not exist" + ) + await dao.delete(override_id) + await regenerate_for_child( + runtime.database, + override.child_id, + **_generation_kwargs(hass, runtime), + ) + + return _with_errors(_handler) + + +async def _export_history_csv(_call: Any) -> None: + raise HomeAssistantError( + "CSV history export is not implemented until Feature 13" + ) + + +def _manage_child( + hass: HomeAssistant, find_runtime: FindRuntime +) -> ServiceHandler: + async def _handler(call: Any) -> None: + database = _require_runtime(hass, find_runtime).database + data = call.data + action = data["action"] + if action == MANAGE_CHILD_CREATE: + display_name = data.get("display_name") + if display_name is None: + raise HomeAssistantError( + "display_name is required when action is create" + ) + kwargs: dict[str, Any] = {} + if "colour" in data: + kwargs["colour"] = data["colour"] + if "avatar_ref" in data: + kwargs["avatar_ref"] = data["avatar_ref"] + if "sort_order" in data: + kwargs["sort_order"] = data["sort_order"] + await children_layer.create_child( + database, display_name, **kwargs + ) + return + if action == MANAGE_CHILD_EDIT: + child_id = data.get("child_id") + if child_id is None: + raise HomeAssistantError( + "child_id is required when action is edit" + ) + kwargs = {} + if "display_name" in data: + kwargs["display_name"] = data["display_name"] + if "colour" in data: + kwargs["colour"] = data["colour"] + if "avatar_ref" in data: + kwargs["avatar_ref"] = data["avatar_ref"] + if "sort_order" in data: + kwargs["sort_order"] = data["sort_order"] + await children_layer.edit_child(database, child_id, **kwargs) + return + if action == MANAGE_CHILD_SET_ACTIVE: + child_id = data.get("child_id") + is_active = data.get("is_active") + if child_id is None: + raise HomeAssistantError( + "child_id is required when action is set_active" + ) + if is_active is None: + raise HomeAssistantError( + "is_active is required when action is set_active" + ) + await children_layer.set_child_active( + database, child_id, is_active + ) + return + ordered_ids = data.get("ordered_ids") + if ordered_ids is None: + raise HomeAssistantError( + "ordered_ids is required when action is reorder" + ) + await children_layer.reorder_children(database, ordered_ids) + + return _with_errors(_handler) + + +def async_register_services( + hass: HomeAssistant, + *, + find_runtime: FindRuntime, + regenerate_handler: ServiceHandler, +) -> None: + """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): + return + gate = permission_gate + specs: list[tuple[str, ServiceHandler, object]] = [ + (SERVICE_REGENERATE, regenerate_handler, None), + ( + SERVICE_COMPLETE_QUEST, + _complete_quest(hass, find_runtime), + SCHEMA_COMPLETE_QUEST, + ), + ( + SERVICE_UNCOMPLETE_QUEST, + _uncomplete_quest(hass, find_runtime), + SCHEMA_UNCOMPLETE_QUEST, + ), + ( + SERVICE_CREATE_QUEST_DEFINITION, + _create_quest_definition(hass, find_runtime), + SCHEMA_CREATE_QUEST_DEFINITION, + ), + ( + SERVICE_UPDATE_QUEST_DEFINITION, + _update_quest_definition(hass, find_runtime), + SCHEMA_UPDATE_QUEST_DEFINITION, + ), + ( + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + _set_quest_definition_active(hass, find_runtime), + SCHEMA_SET_QUEST_DEFINITION_ACTIVE, + ), + ( + SERVICE_SET_PRESENCE_PATTERN, + _set_presence_pattern(hass, find_runtime), + SCHEMA_SET_PRESENCE_PATTERN, + ), + ( + SERVICE_CREATE_PRESENCE_OVERRIDE, + _create_presence_override(hass, find_runtime), + SCHEMA_CREATE_PRESENCE_OVERRIDE, + ), + ( + SERVICE_DELETE_PRESENCE_OVERRIDE, + _delete_presence_override(hass, find_runtime), + SCHEMA_DELETE_PRESENCE_OVERRIDE, + ), + ( + SERVICE_EXPORT_HISTORY_CSV, + _export_history_csv, + SCHEMA_EXPORT_HISTORY_CSV, + ), + ( + SERVICE_MANAGE_CHILD, + _manage_child(hass, find_runtime), + SCHEMA_MANAGE_CHILD, + ), + ] + + def _database_of(_call: Any) -> Any: + return _require_runtime(hass, find_runtime).database + + for service, handler, schema in specs: + gated = gate( + service, SERVICE_POLICY[service], _database_of + )(handler) + hass.services.async_register(DOMAIN, service, gated, schema=schema) + + +def async_deregister_services(hass: HomeAssistant) -> None: + """Remove every canonical service when the last entry unloads.""" + for service in DOMAIN_SERVICES: + if hass.services.has_service(DOMAIN, service): + hass.services.async_remove(DOMAIN, service) diff --git a/custom_components/nestquest/services.yaml b/custom_components/nestquest/services.yaml index eeae053..892d318 100644 --- a/custom_components/nestquest/services.yaml +++ b/custom_components/nestquest/services.yaml @@ -9,4 +9,318 @@ regenerate: horizon, using the same idempotent materialization as the daily rollover and the startup backfill. Safe to call at any time: a re-run refreshes the same rows instead of duplicating them, and completed instances are never - touched. \ No newline at end of file + touched. + +complete_quest: + name: Complete quest + description: Mark a quest instance as completed. + fields: + instance_id: + name: Instance ID + description: The quest instance to complete. + required: true + selector: + number: + mode: box + actor: + name: Actor + description: Who completed the quest (panel or user). + required: true + selector: + select: + options: + - panel + - user + actor_child_id: + name: Actor child ID + description: The tapped child profile. Required when actor is panel. + required: false + selector: + number: + mode: box + actor_user_id: + name: Actor user ID + description: Home Assistant user id when actor is user and no call context is present. + required: false + selector: + text: + +uncomplete_quest: + name: Uncomplete quest + description: Undo a quest instance completion. + fields: + instance_id: + name: Instance ID + description: The quest instance to uncomplete. + required: true + selector: + number: + mode: box + actor: + name: Actor + description: Who is undoing the completion (panel or user). + required: true + selector: + select: + options: + - panel + - user + actor_child_id: + name: Actor child ID + description: The tapped child profile. Required when actor is panel. + required: false + selector: + number: + mode: box + actor_user_id: + name: Actor user ID + description: Home Assistant user id when actor is user and no call context is present. + required: false + selector: + text: + +create_quest_definition: + name: Create quest definition + description: Create a quest definition with its schedule rule, assignees and windows. + fields: + title: + name: Title + description: Display title for the quest. + required: true + selector: + text: + rule: + name: Rule + description: Recurrence rule as a mapping (rule_type plus shape fields). + required: true + selector: + object: + assignee_child_ids: + name: Assignee child IDs + description: Child ids this definition is assigned to. + required: true + selector: + object: + windows: + name: Windows + description: Day windows (morning, afternoon, evening), optionally with a due time. + required: true + selector: + object: + description: + name: Description + description: Optional longer description. + required: false + selector: + text: + icon: + name: Icon + description: Optional icon name. + required: false + selector: + text: + +update_quest_definition: + name: Update quest definition + description: Edit a quest definition's title, description, icon, rule or windows. + fields: + definition_id: + name: Definition ID + description: The quest definition to update. + required: true + selector: + number: + mode: box + title: + name: Title + description: Replacement display title. + required: false + selector: + text: + description: + name: Description + description: Replacement description, or null to clear. + required: false + selector: + text: + icon: + name: Icon + description: Replacement icon, or null to clear. + required: false + selector: + text: + rule: + name: Rule + description: Replacement recurrence rule as a mapping. + required: false + selector: + object: + windows: + name: Windows + description: Replacement day windows. + required: false + selector: + object: + +set_quest_definition_active: + name: Set quest definition active + description: Deactivate or reactivate a quest definition. + fields: + definition_id: + name: Definition ID + description: The quest definition to update. + required: true + selector: + number: + mode: box + is_active: + name: Active + description: Whether the definition should generate future instances. + required: true + selector: + boolean: + +set_presence_pattern: + name: Set presence pattern + description: Upsert a child's repeating presence schedule. + fields: + child_id: + name: Child ID + description: The child whose schedule is being set. + required: true + selector: + number: + mode: box + cycle_length_weeks: + name: Cycle length weeks + description: Number of weeks in the repeating cycle (1 to 4). + required: true + selector: + number: + mode: box + min: 1 + max: 4 + anchor_date: + name: Anchor date + description: Cycle anchor as a YYYY-MM-DD date. + required: true + selector: + date: + pattern: + name: Pattern + description: Mapping of week index to present weekday ints (Monday is 0). + required: true + selector: + object: + +create_presence_override: + name: Create presence override + description: Create a date-range presence override for a child. + fields: + child_id: + name: Child ID + description: The child the override applies to. + required: true + selector: + number: + mode: box + start_date: + name: Start date + description: Inclusive start date (YYYY-MM-DD). + required: true + selector: + date: + end_date: + name: End date + description: Inclusive end date (YYYY-MM-DD). + required: true + selector: + date: + is_present: + name: Present + description: Whether the child is present for this range. + required: true + selector: + boolean: + note: + name: Note + description: Optional note stored with the override. + required: false + selector: + text: + +delete_presence_override: + name: Delete presence override + description: Delete a presence override by id. + fields: + override_id: + name: Override ID + description: The override to delete. + required: true + selector: + number: + mode: box + +export_history_csv: + name: Export history CSV + description: Export completion history as CSV. Not implemented until Feature 13. + +manage_child: + name: Manage child + description: Create, edit, activate or reorder child profiles. + fields: + action: + name: Action + description: create, edit, set_active, or reorder. + required: true + selector: + select: + options: + - create + - edit + - set_active + - reorder + child_id: + name: Child ID + description: Required for edit and set_active. + required: false + selector: + number: + mode: box + display_name: + name: Display name + description: Required for create; optional for edit. + required: false + selector: + text: + colour: + name: Colour + description: Optional colour string. + required: false + selector: + text: + avatar_ref: + name: Avatar + description: Optional avatar reference. + required: false + selector: + text: + sort_order: + name: Sort order + description: Optional panel sort order. + required: false + selector: + number: + mode: box + is_active: + name: Active + description: Required for set_active. + required: false + selector: + boolean: + ordered_ids: + name: Ordered IDs + description: Complete permutation of child ids. Required for reorder. + required: false + selector: + object: diff --git a/tests/conftest.py b/tests/conftest.py index b6603e8..f9e31bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -177,7 +177,11 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" _data_entry_flow_mock.FlowResult = dict -class ConfigEntryNotReady(Exception): +class HomeAssistantError(Exception): + """Stand-in mirroring homeassistant.exceptions.HomeAssistantError.""" + + +class ConfigEntryNotReady(HomeAssistantError): """Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady. HA retries setup when this is raised; the tests assert the corrupt @@ -185,7 +189,13 @@ class ConfigEntryNotReady(Exception): """ +class Unauthorized(HomeAssistantError): + """Stand-in mirroring homeassistant.exceptions.Unauthorized.""" + + +_exceptions_mock.HomeAssistantError = HomeAssistantError _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady +_exceptions_mock.Unauthorized = Unauthorized import voluptuous as vol @@ -325,7 +335,7 @@ class ServiceRegistry: self._services: dict[tuple[str, str], object] = {} def async_register(self, domain, service, func, schema=None, supports_response=None): - self._services[(domain, service)] = func + self._services[(domain, service)] = (func, schema) return None def has_service(self, domain, service): @@ -335,11 +345,26 @@ class ServiceRegistry: self._services.pop((domain, service), None) return None - async def call(self, domain, service, data=None): - func = self._services.get((domain, service)) - if func is None: + async def call(self, domain, service, data=None, context=None): + registered = self._services.get((domain, service)) + if registered is None: raise KeyError(f"service {domain}.{service} not registered") - call = SimpleNamespace(domain=domain, service=service, data=data or {}) + func, schema = registered + payload = dict(data or {}) + if schema is not None: + payload = schema(payload) + if context is None: + context_obj = SimpleNamespace(user_id=None) + elif isinstance(context, dict): + context_obj = SimpleNamespace(**context) + else: + context_obj = context + call = SimpleNamespace( + domain=domain, + service=service, + data=payload, + context=context_obj, + ) result = func(call) if inspect.isawaitable(result): await result diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index 0d86cb8..b1ee155 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -455,6 +455,20 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None: assert _with_db(tmp_path, "override-list-unknown.db")(_body) == [] +def test_override_get_round_trip(tmp_path) -> None: + async def _body(database, schedules, overrides, children, child): + record = await overrides.create( + child.id, "2026-09-10", "2026-09-11", True, note="camp" + ) + found = await overrides.get(record.id) + assert found == record + assert await overrides.get(999) is None + return found + + found = _with_db(tmp_path, "override-get.db")(_body) + assert found.note == "camp" + + def test_override_delete_round_trip(tmp_path) -> None: async def _body(database, schedules, overrides, children, child): record = await overrides.create( diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index b81f3f9..b0db61e 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -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.const import ( + CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS, @@ -16,6 +17,13 @@ from custom_components.nestquest.const import ( 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): 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 - 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 hass.services.has_service(DOMAIN, SERVICE_REGENERATE) @@ -555,7 +563,7 @@ async def test_regenerate_service_registered_and_materializes( database = entry.runtime_data.database 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) 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 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 database = entry.runtime_data.database 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) 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 - entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry) - entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry) + entry_a = _wire( + 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_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( 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) end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() 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) # 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. assert await async_unload_entry(hass, entry_b) is True diff --git a/tests/test_permission_matrix.py b/tests/test_permission_matrix.py new file mode 100644 index 0000000..14e521e --- /dev/null +++ b/tests/test_permission_matrix.py @@ -0,0 +1,238 @@ +"""Feature 09 permission test matrix across every registered service.""" +from __future__ import annotations + +import datetime + +import pytest +from homeassistant.exceptions import Unauthorized + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry +from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, + DEFAULT_HORIZON_DAYS, + DOMAIN, + DOMAIN_SERVICES, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_UNCOMPLETE_QUEST, + SERVICE_UPDATE_QUEST_DEFINITION, +) +from custom_components.nestquest.service_policy import ( + ADMIN_ONLY, + OPEN, + SERVICE_POLICY, +) + +ADMIN_ID = "admin-1" +ADMIN_CTX = {"user_id": ADMIN_ID} +NON_ADMIN_ID = "non-admin" +NON_ADMIN_CTX = {"user_id": NON_ADMIN_ID} +KIOSK_ID = "kiosk-panel" +KIOSK_CTX = {"user_id": KIOSK_ID} + + +def test_every_registered_service_is_in_the_operation_registry() -> None: + """The registry is total: a service outside it could never be gated.""" + missing = [ + service for service in DOMAIN_SERVICES if service not in SERVICE_POLICY + ] + extra = [ + service for service in SERVICE_POLICY if service not in DOMAIN_SERVICES + ] + assert not missing, f"registered but ungated: {missing}" + assert not extra, f"gated but never registered: {extra}" + assert set(SERVICE_POLICY.values()) == {OPEN, ADMIN_ONLY} + + +async def _setup(hass, make_entry, *, admin=True): + data = {CONF_ADMIN_USER_IDS: [ADMIN_ID]} if admin else {} + entry = wire_entry_to_registry(make_entry(data=data), hass.registry) + assert await async_setup_entry(hass, entry) is True + return entry + + +async def _seed(hass, entry) -> dict: + """Seed one child, one definition, and materialized instances.""" + from custom_components.nestquest.children import list_children + from custom_components.nestquest.dao_instances import QuestInstancesDao + + database = entry.runtime_data.database + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [(await list_children(database))[0].id], + "windows": ["morning"], + }, + context=ADMIN_CTX, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) + child = (await list_children(database))[0] + today = datetime.date.today().isoformat() + end = ( + datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + instances = await QuestInstancesDao(database).list_by_date_range( + child.id, today, end + ) + assert instances + from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + ) + + return { + "child_id": child.id, + "instance_id": instances[0].id, + "definition_id": instances[0].definition_id, + "override": await PresenceOverridesDao(database).create( + child.id, "2027-01-01", "2027-01-02", False + ), + "today": today, + "database": database, + } + + +def _admin_success_payload(service: str, ctx: dict) -> dict | None: + """A schema-valid, end-to-end-successful payload per admin service.""" + if service == SERVICE_UNCOMPLETE_QUEST: + return {"instance_id": ctx["instance_id"], "actor": "user"} + if service == SERVICE_CREATE_QUEST_DEFINITION: + return { + "title": "Second quest", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [ctx["child_id"]], + "windows": ["afternoon"], + } + if service == SERVICE_UPDATE_QUEST_DEFINITION: + return {"definition_id": ctx["definition_id"], "title": "Brush teeth 2"} + if service == SERVICE_SET_QUEST_DEFINITION_ACTIVE: + return {"definition_id": ctx["definition_id"], "is_active": False} + if service == SERVICE_SET_PRESENCE_PATTERN: + return { + "child_id": ctx["child_id"], + "cycle_length_weeks": 1, + "anchor_date": ctx["today"], + "pattern": {0: [0, 1, 2, 3, 4, 5, 6]}, + } + if service == SERVICE_CREATE_PRESENCE_OVERRIDE: + return { + "child_id": ctx["child_id"], + "start_date": "2027-03-01", + "end_date": "2027-03-02", + "is_present": False, + } + if service == SERVICE_DELETE_PRESENCE_OVERRIDE: + return {"override_id": ctx["override"].id} + if service == SERVICE_EXPORT_HISTORY_CSV: + return None # raises HomeAssistantError (Feature 13) — not Unauthorized + if service == SERVICE_MANAGE_CHILD: + return {"action": "create", "display_name": "Bo"} + if service == SERVICE_REGENERATE: + return {} + raise AssertionError(f"unexpected service {service}") + + +_ADMIN_ONLY = [ + service for service, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY +] + + +@pytest.mark.parametrize("service", _ADMIN_ONLY) +async def test_admin_succeeds_on_every_admin_only_service( + hass, make_entry, service +) -> None: + from homeassistant.exceptions import HomeAssistantError + + entry = await _setup(hass, make_entry) + ctx = await _seed(hass, entry) + if service == SERVICE_EXPORT_HISTORY_CSV: + # The gate passes; the Feature 13 stub raises its "not + # implemented" HomeAssistantError — NOT Unauthorized. + with pytest.raises(HomeAssistantError, match="Feature 13"): + await hass.services.call( + DOMAIN, service, None, context=ADMIN_CTX + ) + return + await hass.services.call( + DOMAIN, service, _admin_success_payload(service, ctx), context=ADMIN_CTX + ) + + +@pytest.mark.parametrize("service", _ADMIN_ONLY) +async def test_non_admin_refused_on_every_admin_only_service( + hass, make_entry, service +) -> None: + entry = await _setup(hass, make_entry) + ctx = await _seed(hass, entry) + if service != SERVICE_EXPORT_HISTORY_CSV: + await hass.services.call( + DOMAIN, + service, + _admin_success_payload(service, ctx), + context=ADMIN_CTX, + ) + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + service, + _admin_success_payload(service, ctx), + context=NON_ADMIN_CTX, + ) + + +async def test_non_admin_allowed_on_complete_quest(hass, make_entry) -> None: + from custom_components.nestquest.completion import instance_state + + entry = await _setup(hass, make_entry) + ctx = await _seed(hass, entry) + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": ctx["instance_id"], + "actor": "panel", + "actor_child_id": ctx["child_id"], + }, + context=NON_ADMIN_CTX, + ) + assert ( + await instance_state(ctx["database"], ctx["instance_id"]) == "done" + ) + + +async def test_kiosk_refused_on_uncomplete_and_regenerate( + hass, make_entry +) -> None: + entry = await _setup(hass, make_entry) + ctx = await _seed(hass, entry) + from custom_components.nestquest.admin_allowlist import list_admin_ids + + assert KIOSK_ID not in await list_admin_ids(ctx["database"]) + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + {"instance_id": ctx["instance_id"], "actor": "user"}, + context=KIOSK_CTX, + ) + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, SERVICE_REGENERATE, context=KIOSK_CTX + ) \ No newline at end of file diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 0000000..b71ffce --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,122 @@ +"""Tests for the Feature 09 permission gate's audit logging.""" +from __future__ import annotations + +import logging +import re + +import pytest +from homeassistant.exceptions import Unauthorized + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry +from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, + DOMAIN, + SERVICE_MANAGE_CHILD, +) + +ADMIN_ID = "admin-1" +ADMIN_CTX = {"user_id": ADMIN_ID} +_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") + + +async def _set_up(hass, make_entry): + entry = wire_entry_to_registry( + make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}), hass.registry + ) + assert await async_setup_entry(hass, entry) is True + return entry + + +async def test_denied_call_logs_warning_with_fields( + hass, make_entry, caplog +) -> None: + entry = await _set_up(hass, make_entry) + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context={"user_id": "kiosk-panel"}, + ) + denials = [ + record + for record in caplog.records + if record.levelno == logging.WARNING + and "denied" in record.getMessage() + ] + assert len(denials) == 1 + message = denials[0].getMessage() + assert SERVICE_MANAGE_CHILD in message + assert "kiosk-panel" in message + assert _TIMESTAMP.search(message), message + + +async def test_denied_no_context_logs_user_none( + hass, make_entry, caplog +) -> None: + entry = await _set_up(hass, make_entry) + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + ) + denials = [ + record + for record in caplog.records + if record.levelno == logging.WARNING + and "denied" in record.getMessage() + ] + assert len(denials) == 1 + message = denials[0].getMessage() + assert "none" in message + assert _TIMESTAMP.search(message), message + + +async def test_successful_admin_call_logs_debug_with_fields( + hass, make_entry, caplog +) -> None: + caplog.set_level(logging.DEBUG, logger="custom_components.nestquest") + entry = await _set_up(hass, make_entry) + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + allowed = [ + record + for record in caplog.records + if record.levelno == logging.DEBUG + and "allowed" in record.getMessage() + ] + assert len(allowed) == 1 + message = allowed[0].getMessage() + assert SERVICE_MANAGE_CHILD in message + assert ADMIN_ID in message + assert _TIMESTAMP.search(message), message + + +async def test_denied_log_carries_no_personal_data_beyond_user_id( + hass, make_entry, caplog +) -> None: + """The log line names the caller's user id only: the payload's + display name must not leak into the audit trail.""" + entry = await _set_up(hass, make_entry) + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context={"user_id": "kiosk-panel"}, + ) + denials = [ + record.getMessage() + for record in caplog.records + if record.levelno == logging.WARNING and "denied" in record.getMessage() + ] + assert denials, "expected a denial log line" + assert "Ada" not in denials[0] + assert "display_name" not in denials[0] \ No newline at end of file diff --git a/tests/test_service_policy.py b/tests/test_service_policy.py new file mode 100644 index 0000000..0afa85c --- /dev/null +++ b/tests/test_service_policy.py @@ -0,0 +1,32 @@ +"""Tests for the Feature 09 admin-only versus open service registry.""" +from __future__ import annotations + +from custom_components.nestquest.const import ( + DOMAIN_SERVICES, + SERVICE_COMPLETE_QUEST, +) +from custom_components.nestquest.service_policy import ( + ADMIN_ONLY, + OPEN, + SERVICE_POLICY, +) + + +def test_policy_covers_exactly_the_registered_services() -> None: + assert frozenset(SERVICE_POLICY) == frozenset(DOMAIN_SERVICES) + + +def test_only_complete_quest_is_open() -> None: + open_services = { + name for name, access in SERVICE_POLICY.items() if access == OPEN + } + assert open_services == {SERVICE_COMPLETE_QUEST} + assert all( + access == ADMIN_ONLY + for name, access in SERVICE_POLICY.items() + if name != SERVICE_COMPLETE_QUEST + ) + + +def test_policy_values_are_only_open_or_admin_only() -> None: + assert set(SERVICE_POLICY.values()) <= {OPEN, ADMIN_ONLY} diff --git a/tests/test_services.py b/tests/test_services.py index 68f2cde..d242a3b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,9 +1,33 @@ -"""Tests for the NestQuest services.yaml declaration file.""" +"""Tests for NestQuest service declarations and domain-global handlers.""" from __future__ import annotations import re from pathlib import Path +import pytest +import voluptuous as vol +from homeassistant.exceptions import HomeAssistantError, Unauthorized + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry, async_unload_entry +from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, + DOMAIN, + DOMAIN_SERVICES, + SERVICE_COMPLETE_QUEST, + SERVICE_CREATE_PRESENCE_OVERRIDE, + SERVICE_CREATE_QUEST_DEFINITION, + SERVICE_DELETE_PRESENCE_OVERRIDE, + SERVICE_EXPORT_HISTORY_CSV, + SERVICE_MANAGE_CHILD, + SERVICE_REGENERATE, + SERVICE_SET_PRESENCE_PATTERN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + SERVICE_UNCOMPLETE_QUEST, +) +from custom_components.nestquest.service_policy import ADMIN_ONLY, SERVICE_POLICY + SERVICES_PATH = ( Path(__file__).parent.parent / "custom_components" @@ -12,10 +36,47 @@ SERVICES_PATH = ( ) +def _wire(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: + match = re.search(rf"^{name}:\s*$", text, re.MULTILINE) + assert match is not None, f"services.yaml must declare {name!r}" + section = text[match.end() :] + next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE) + if next_service is not None: + section = section[: next_service.start()] + return section + + def test_services_yaml_exists() -> None: assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}" +def test_services_yaml_declares_every_canonical_service() -> None: + text = SERVICES_PATH.read_text(encoding="utf-8") + declared = re.findall(r"^(?![#\s])(\w+):\s*$", text, re.MULTILINE) + assert declared == list(DOMAIN_SERVICES) or set(declared) == set( + DOMAIN_SERVICES + ) + for name in DOMAIN_SERVICES: + section = _service_section(text, name) + assert re.search(r"description:", section), ( + f"{name} must carry a description" + ) + + def test_services_yaml_declares_regenerate() -> None: text = SERVICES_PATH.read_text(encoding="utf-8") assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), ( @@ -27,15 +88,506 @@ def test_services_yaml_regenerate_has_description_and_no_fields() -> None: """The regenerate service is documented with a clear description and no required fields (there is no ``fields`` block under it).""" text = SERVICES_PATH.read_text(encoding="utf-8") - match = re.search(r"^regenerate:\s*$", text, re.MULTILINE) - assert match is not None - section = text[match.end():] - next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE) - if next_service is not None: - section = section[: next_service.start()] + section = _service_section(text, "regenerate") assert re.search(r"description:", section), ( "regenerate must carry a description" ) assert "fields:" not in section, ( "regenerate declares no required fields" - ) \ No newline at end of file + ) + + +def test_services_yaml_other_services_declare_fields() -> None: + text = SERVICES_PATH.read_text(encoding="utf-8") + for name in DOMAIN_SERVICES: + if name in (SERVICE_REGENERATE, SERVICE_EXPORT_HISTORY_CSV): + continue + section = _service_section(text, name) + assert "fields:" in section, f"{name} must declare fields" + + +async def test_all_canonical_services_registered_after_setup( + hass, make_entry +) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + for service in DOMAIN_SERVICES: + assert hass.services.has_service(DOMAIN, service), service + + +async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert await async_unload_entry(hass, entry) is True + for service in DOMAIN_SERVICES: + assert not hass.services.has_service(DOMAIN, service), service + + +async def test_unload_one_of_two_entries_keeps_services( + hass, make_entry +) -> None: + entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry) + entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry) + assert await async_setup_entry(hass, entry_a) is True + assert await async_setup_entry(hass, entry_b) is True + assert await async_unload_entry(hass, entry_a) is True + for service in DOMAIN_SERVICES: + assert hass.services.has_service(DOMAIN, service), service + assert await async_unload_entry(hass, entry_b) is True + for service in DOMAIN_SERVICES: + assert not hass.services.has_service(DOMAIN, service), service + + +async def test_manage_child_create_persists_child(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 + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + children = await list_children(entry.runtime_data.database) + assert [child.display_name for child in children] == ["Ada"] + + +async def test_create_and_set_quest_definition_active( + hass, make_entry +) -> None: + from custom_components.nestquest.children import list_children + from custom_components.nestquest.quest_definitions import ( + list_active_definitions, + ) + from custom_components.nestquest.dao_rules import QuestDefinitionsDao + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + context=ADMIN_CTX, + ) + active = await list_active_definitions(entry.runtime_data.database) + assert len(active) == 1 + definition_id = active[0].definition.id + await hass.services.call( + DOMAIN, + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + {"definition_id": definition_id, "is_active": False}, + context=ADMIN_CTX, + ) + assert await list_active_definitions(entry.runtime_data.database) == [] + stored = await QuestDefinitionsDao(entry.runtime_data.database).get( + definition_id + ) + assert stored is not None + assert stored.is_active is False + + +async def test_complete_and_uncomplete_quest_call_business_layer( + hass, make_entry +) -> None: + import datetime + + from custom_components.nestquest.children import list_children + from custom_components.nestquest.completion import instance_state + from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + context=ADMIN_CTX, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) + today = datetime.date.today().isoformat() + end = ( + datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + records = await QuestInstancesDao( + entry.runtime_data.database + ).list_by_date_range(child.id, today, end) + assert records + instance_id = records[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance_id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + assert ( + await instance_state(entry.runtime_data.database, instance_id) + == "done" + ) + await hass.services.call( + DOMAIN, + SERVICE_UNCOMPLETE_QUEST, + { + "instance_id": instance_id, + "actor": "user", + "actor_user_id": ADMIN_ID, + }, + context=ADMIN_CTX, + ) + assert ( + await instance_state(entry.runtime_data.database, instance_id) + == "open" + ) + + +async def test_set_presence_pattern_regenerates_child_instances( + hass, make_entry +) -> None: + import datetime + + from custom_components.nestquest.children import list_children + from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + context=ADMIN_CTX, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) + today = datetime.date.today().isoformat() + end = ( + datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + dao = QuestInstancesDao(entry.runtime_data.database) + assert await dao.list_by_date_range(child.id, today, end) + await hass.services.call( + DOMAIN, + SERVICE_SET_PRESENCE_PATTERN, + { + "child_id": child.id, + "cycle_length_weeks": 1, + "anchor_date": today, + "pattern": {0: []}, + }, + context=ADMIN_CTX, + ) + assert await dao.list_by_date_range(child.id, today, end) == [] + + +async def test_presence_override_create_and_delete_regenerate( + hass, make_entry +) -> None: + import datetime + + from custom_components.nestquest.children import list_children + from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS + from custom_components.nestquest.dao_instances import QuestInstancesDao + from custom_components.nestquest.dao_presence import PresenceOverridesDao + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + context=ADMIN_CTX, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) + today = datetime.date.today() + end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + dao = QuestInstancesDao(entry.runtime_data.database) + before = await dao.list_by_date_range( + child.id, today.isoformat(), end.isoformat() + ) + assert before + await hass.services.call( + DOMAIN, + SERVICE_CREATE_PRESENCE_OVERRIDE, + { + "child_id": child.id, + "start_date": today.isoformat(), + "end_date": end.isoformat(), + "is_present": False, + }, + context=ADMIN_CTX, + ) + assert ( + await dao.list_by_date_range( + child.id, today.isoformat(), end.isoformat() + ) + == [] + ) + override = ( + await PresenceOverridesDao( + entry.runtime_data.database + ).list_by_child_and_range( + child.id, today.isoformat(), end.isoformat() + ) + )[0] + await hass.services.call( + DOMAIN, + SERVICE_DELETE_PRESENCE_OVERRIDE, + {"override_id": override.id}, + context=ADMIN_CTX, + ) + after = await dao.list_by_date_range( + child.id, today.isoformat(), end.isoformat() + ) + assert len(after) == len(before) + + +async def test_export_history_csv_raises_home_assistant_error( + 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(HomeAssistantError, match="Feature 13"): + 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: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)): + await hass.services.call(DOMAIN, SERVICE_COMPLETE_QUEST, {}) + with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)): + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": True, "actor": "panel", "actor_child_id": 1}, + ) + with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"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) == [] + + +_MINIMAL_PAYLOADS = { + "uncomplete_quest": {"instance_id": 1, "actor": "user"}, + "create_quest_definition": { + "title": "T", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [1], + "windows": ["morning"], + }, + "update_quest_definition": {"definition_id": 1}, + "set_quest_definition_active": {"definition_id": 1, "is_active": True}, + "set_presence_pattern": { + "child_id": 1, + "cycle_length_weeks": 1, + "anchor_date": "2026-09-19", + "pattern": {0: [0]}, + }, + "create_presence_override": { + "child_id": 1, + "start_date": "2026-09-19", + "end_date": "2026-09-19", + "is_present": False, + }, + "delete_presence_override": {"override_id": 1}, + "export_history_csv": {}, + "manage_child": {"action": "create", "display_name": "Ada"}, + "regenerate": {}, +} + +_ADMIN_ONLY_SERVICES = [ + name for name, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY +] + + +@pytest.mark.parametrize("service", _ADMIN_ONLY_SERVICES) +async def test_admin_only_service_fails_closed_without_user_context( + hass, make_entry, service +) -> None: + """A call with no user_id is denied on every admin-only service.""" + 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, dict(_MINIMAL_PAYLOADS[service]) + ) + + +async def test_complete_quest_proceeds_without_user_context( + hass, make_entry +) -> None: + """The open service needs no user context: the kiosk panel taps a + child profile and the call goes through with no HA user at all.""" + import datetime + + from custom_components.nestquest.children import list_children + from custom_components.nestquest.completion import instance_state + from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS + from custom_components.nestquest.dao_instances import QuestInstancesDao + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + child = (await list_children(entry.runtime_data.database))[0] + await hass.services.call( + DOMAIN, + SERVICE_CREATE_QUEST_DEFINITION, + { + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child.id], + "windows": ["morning"], + }, + context=ADMIN_CTX, + ) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) + today = datetime.date.today().isoformat() + end = ( + datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) + ).isoformat() + instance_id = ( + await QuestInstancesDao( + entry.runtime_data.database + ).list_by_date_range(child.id, today, end) + )[0].id + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + ) + assert ( + await instance_state(entry.runtime_data.database, instance_id) + == "done" + ) + + +async def test_scheduled_internals_never_call_the_service_layer( + hass, make_entry +) -> None: + """The daily rollover and startup backfill run the materialization + as direct function calls; they never widen the service gate.""" + import custom_components.nestquest as nestquest + + assert not hasattr(nestquest, "_run_horizon_materialization_service") + source = Path(nestquest.__file__).read_text(encoding="utf-8") + assert "hass.services.call" not in source, ( + "scheduled internals must call the materialization directly, " + "never through hass.services.call" + )