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:
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) == []
|
||||||
|
|||||||
Reference in New Issue
Block a user