diff --git a/api/nestquest_core.py b/api/nestquest_core.py index 6257dab..45e795a 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -124,8 +124,13 @@ core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events") #: the snapshot builder's ``settings`` parameter takes. core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings") +#: The children business layer (``core.children.create_child`` et al.) +#: the admin plane's children routes call. +core_children = importlib.import_module(f"{_CORE_MODULE_NAME}.children") + __all__ = [ "core", + "core_children", "core_completion", "core_db", "core_events", diff --git a/api/routes_admin.py b/api/routes_admin.py index b7164c7..ff88551 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -8,20 +8,48 @@ the router level, so each later admin route added to this router inherits the check). The panel service token is refused outright on this plane (403), never treated as a JWT. -This task is the AUTH probe only: the ping route is a thin adapter -that performs NO business logic and NO auth of its own — it reuses the -router's already-verified claims (FastAPI caches a dependency's result -per request, so the check runs exactly once) and returns the status -plus the token's subject. +The children routes are THIN adapters over the bundled core (Feature +15): each handler validates the request's shape with a Pydantic model, +resolves the live database off ``app.state.db``, calls ONE +:mod:`nestquest_core.children` business function — the name policy, +the no-explicit-NULL rule, the real-bool check and the permutation +check all live THERE, never in a handler — and serializes the returned +:class:`~nestquest_core.dao_children.ChildRecord` into the documented +payload (``id``, ``display_name``, ``colour``, ``avatar_ref``, +``sort_order``, ``is_active``). + +Error mapping (every children route, deliberately narrow): + +- Malformed input the body model or the path parser rejects (a missing + or wrong-typed field, a non-integer child id) is FastAPI's 422 before + any handler code runs. +- A core ``ValueError`` reporting a NON-EXISTENT child (edit or + set_active on an unknown id) is mapped to 404. +- Every OTHER core ``ValueError`` — an empty display name, an explicit + ``null`` colour/avatar_ref (clearing is not supported), a reorder + list that is not a complete permutation of the children (missing, + duplicate, or unknown ids) — is mapped to 422: the body was + well-formed JSON but semantically invalid, the same failure class + Pydantic reports. The core rejects these BEFORE any write, so a 422 + never leaves a half-applied change behind. + +PATCH edit semantics: the body model's fields are optional and only +SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so +an omitted field is never passed to +:func:`nestquest_core.children.edit_child` and stays unchanged — while +an explicit ``null`` IS passed and is rejected by the core rather than +silently ignored. """ from __future__ import annotations -from typing import Annotated +from typing import Annotated, NoReturn -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from api.auth import require_admin +from api.database import DatabaseState +from api.nestquest_core import core_children #: All admin-plane routes share this router; the ONE admin dependency #: (service-token refusal + JWT verification + nestquest-admins group @@ -42,6 +70,108 @@ class AdminPingResponse(BaseModel): sub: str | None +class AdminChildResponse(BaseModel): + """One child profile as the admin plane serializes it. + + Mirrors :class:`~nestquest_core.dao_children.ChildRecord` minus the + internal ``created_at`` stamp. + """ + + id: int + display_name: str + colour: str | None + avatar_ref: str | None + sort_order: int + is_active: bool + + +class AdminChildListResponse(BaseModel): + """The children list: every profile in the household's display order.""" + + children: list[AdminChildResponse] + + +class AdminChildCreateRequest(BaseModel): + """The body of ``POST /api/v1/admin/children``. + + ``display_name`` is required (the core trims it and rejects an + empty name); the other fields are optional and default through the + core layer (``colour``/``avatar_ref`` NULL, ``sort_order`` 0). + """ + + display_name: str + colour: str | None = None + avatar_ref: str | None = None + sort_order: int = 0 + + +class AdminChildEditRequest(BaseModel): + """The body of ``PATCH /api/v1/admin/children/{child_id}``. + + Every field is optional; ONLY the fields the request supplies are + forwarded to :func:`nestquest_core.children.edit_child` (the route + dumps the model with ``exclude_unset``), so an omitted field stays + unchanged while an explicit ``null`` — a supplied value — reaches + the core and is rejected there rather than silently ignored. + """ + + display_name: str | None = None + colour: str | None = None + avatar_ref: str | None = None + sort_order: int | None = None + + +class AdminChildActiveRequest(BaseModel): + """The body of ``PATCH /api/v1/admin/children/{child_id}/active``.""" + + is_active: bool + + +class AdminChildReorderRequest(BaseModel): + """The body of ``POST /api/v1/admin/children/reorder``. + + ``ordered_ids`` must be a complete permutation of the household's + child ids (the core rejects a partial, duplicate, or unknown list + before any write). + """ + + ordered_ids: list[int] + + +#: 404 detail for a child id the core layer reports as non-existent. +_CHILD_NOT_FOUND_DETAIL = "Child not found" + + +def _raise_child_error(error: ValueError) -> NoReturn: + """Map a core children ``ValueError`` onto 404 or 422 and raise it. + + The core layer raises ``ValueError`` for two distinct situations, + and this mapping is the children routes' one piece of adapter + logic: a non-existent child on a single-id route (edit, set_active + — the message reads ``child N does not exist``) becomes 404, the + admin plane's not-found answer; every other core ``ValueError`` + becomes 422 with the core's message as the detail — it names the + field and what to fix, and leaks nothing but that validation rule. + """ + if "does not exist" in str(error): + raise HTTPException( + status_code=404, detail=_CHILD_NOT_FOUND_DETAIL + ) from error + raise HTTPException(status_code=422, detail=str(error)) from error + + +def _child_response(record: core_children.ChildRecord) -> AdminChildResponse: + """Serialize one core ChildRecord into the documented payload.""" + return AdminChildResponse( + id=record.id, + display_name=record.display_name, + colour=record.colour, + avatar_ref=record.avatar_ref, + sort_order=record.sort_order, + is_active=record.is_active, + ) + + @router.get("/ping", summary="Admin plane probe") async def admin_ping( claims: Annotated[dict[str, object], Depends(require_admin)], @@ -62,4 +192,153 @@ async def admin_ping( ) +@router.get( + "/children", + summary="List child profiles", + response_model=AdminChildListResponse, +) +async def admin_list_children(request: Request) -> AdminChildListResponse: + """Return every child profile in display order. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: one + :func:`nestquest_core.children.list_children` call, active AND + inactive children included (the admin screen manages both), in the + core's display order (``sort_order``, then ``id``). + """ + state: DatabaseState = request.app.state.db + records = await core_children.list_children(state.database) + return AdminChildListResponse( + children=[_child_response(record) for record in records] + ) + + +@router.post( + "/children", + summary="Create a child profile", + status_code=201, + response_model=AdminChildResponse, +) +async def admin_create_child( + body: AdminChildCreateRequest, request: Request +) -> AdminChildResponse: + """Create a child profile and return it as stored. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: the body model + carries the shape, and the name policy (trim, reject empty), the + duplicate-name warning and the created_at stamp all live in + :func:`nestquest_core.children.create_child`. Errors are mapped by + :func:`_raise_child_error` — create names no existing child, so a + rejection here is always 422. + """ + state: DatabaseState = request.app.state.db + try: + record = await core_children.create_child( + state.database, + body.display_name, + colour=body.colour, + avatar_ref=body.avatar_ref, + sort_order=body.sort_order, + ) + except ValueError as error: + _raise_child_error(error) + return _child_response(record) + + +@router.patch( + "/children/{child_id}", + summary="Edit a child profile", + response_model=AdminChildResponse, +) +async def admin_edit_child( + child_id: int, body: AdminChildEditRequest, request: Request +) -> AdminChildResponse: + """Edit a child profile; only supplied fields change. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: the supplied + fields are forwarded with ``exclude_unset`` semantics so an omitted + field is never passed to :func:`nestquest_core.children.edit_child` + and stays unchanged, while an explicit ``null`` is passed and + rejected by the core (clearing is not supported). An unknown child + id is mapped to 404 and any other core ``ValueError`` to 422 + (:func:`_raise_child_error`). + """ + state: DatabaseState = request.app.state.db + try: + record = await core_children.edit_child( + state.database, + child_id, + **body.model_dump(exclude_unset=True), + ) + except ValueError as error: + _raise_child_error(error) + return _child_response(record) + + +@router.patch( + "/children/{child_id}/active", + summary="Set a child's active flag", + response_model=AdminChildResponse, +) +async def admin_set_child_active( + child_id: int, body: AdminChildActiveRequest, request: Request +) -> AdminChildResponse: + """Deactivate (or reactivate) a child; the profile is NEVER deleted. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: ``is_active`` + is passed straight to + :func:`nestquest_core.children.set_child_active` — deactivation is + the only removal path (the core has no delete, so completion + history keeps its references). An unknown child id is mapped to + 404 (:func:`_raise_child_error`); the body model already makes the + core's real-bool check unreachable from this route. + """ + state: DatabaseState = request.app.state.db + try: + record = await core_children.set_child_active( + state.database, child_id, body.is_active + ) + except ValueError as error: + _raise_child_error(error) + return _child_response(record) + + +@router.post( + "/children/reorder", + summary="Reorder the children's panel display order", +) +async def admin_reorder_children( + body: AdminChildReorderRequest, request: Request +) -> dict[str, str]: + """Rewrite the children's display order in one atomic core call. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: the posted id + order goes straight to + :func:`nestquest_core.children.reorder_children`, which rejects a + list that is not a complete permutation of the household (missing, + duplicate, or unknown ids) BEFORE any write — mapped to 422 by + :func:`_raise_child_error`, leaving the stored order untouched. On + success the core returns ``None``; the route answers + ``{"status": "ok"}`` (the fresh order is read back via + ``GET /children``). + """ + state: DatabaseState = request.app.state.db + try: + await core_children.reorder_children( + state.database, body.ordered_ids + ) + except ValueError as error: + _raise_child_error(error) + return {"status": "ok"} + + __all__ = ["router"] diff --git a/tests/admin_jwt_harness.py b/tests/admin_jwt_harness.py new file mode 100644 index 0000000..7c19467 --- /dev/null +++ b/tests/admin_jwt_harness.py @@ -0,0 +1,189 @@ +"""Shared admin-plane JWT test harness: local keys, stubbed JWKS. + +The admin-plane API test modules share ONE harness instead of +duplicating it: the auth contract tests (:mod:`tests.test_api_admin_auth`) +and the children route tests (:mod:`tests.test_api_admin_children`) both +drive the ASGI app with httpx using a locally generated RSA key pair and +a stubbed JWKS fetcher — NO network access: + +- ``ADMIN_KEY`` signs the test tokens; :func:`jwks_for` builds the JWKS + document publishing its public half, so genuinely signed tokens + verify, and tokens signed with the second pair (``OTHER_KEY``) fail + the signature check for real. +- :func:`make_token` mints one RS256 JWT the way Authentik would (its + claims, our key), defaulting ``groups`` to the admin group a real + admin's token carries. +- :class:`StubbedFetch` is the counting JWKS fetcher stub. +- :class:`AdminRunner` runs the app's lifespan around an httpx + AsyncClient (httpx's ASGI transport does not run the lifespan, so it + is driven explicitly and shut down in ``__aexit__``'s finally block + even on failure) with the app's JWKS cache swapped for one built + around the stub fetcher. + +The harness carries no tests itself; the ``admin_client`` fixture in +``tests/conftest.py`` hands its runner to the test modules. +""" +from __future__ import annotations + +import base64 +import time + +import httpx +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa + +from api.app import create_app +from api.auth import ADMIN_GROUP, JwksCache +from api.config import ApiConfig + +#: The configured OIDC settings the test apps run with (values a real +#: deployment would get from the NESTQUEST_OIDC_* environment vars). +ISSUER = "https://authentik.example.com/application/o/nestquest/" +AUDIENCE = "nestquest-api" +JWKS_URL = "https://authentik.example.com/application/o/nestquest/jwks/" + +#: The ``kid`` the test tokens carry, matching the JWKS entry. +KID = "admin-test-key-1" + +#: The subject a valid token authenticates as. +SUBJECT = "admin-user-1" + + +def _generate_key() -> rsa.RSAPrivateKey: + """Generate one local RSA key pair (no network, no fixtures).""" + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +#: The key pair whose PUBLIC half the stubbed JWKS publishes — tokens +#: signed with it verify; the second pair below proves they don't when +#: signed otherwise. +ADMIN_KEY = _generate_key() +OTHER_KEY = _generate_key() + + +def _b64url_uint(value: int) -> str: + """Base64url-encode a non-negative integer (no padding), per JWK.""" + raw = value.to_bytes((value.bit_length() + 7) // 8, "big") + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def jwks_for(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, object]: + """Build a JWKS document publishing ``private_key``'s public half.""" + numbers = private_key.public_key().public_numbers() + return { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": _b64url_uint(numbers.n), + "e": _b64url_uint(numbers.e), + } + ] + } + + +class StubbedFetch: + """A JWKS fetcher stub: returns a fixed document, counts calls.""" + + def __init__(self, document: dict[str, object]) -> None: + self.document = document + self.calls = 0 + + async def __call__(self, url: str) -> dict[str, object]: + self.calls += 1 + return self.document + + +def make_token( + signing_key: rsa.RSAPrivateKey = ADMIN_KEY, + *, + kid: str | None = KID, + iss: str = ISSUER, + aud: object = AUDIENCE, + sub: str = SUBJECT, + expires_in: int | None = 300, + nbf_in: int | None = -1, + groups: object = (ADMIN_GROUP,), +) -> str: + """Mint one RS256 JWT the way Authentik would (its claims, our key). + + ``groups`` defaults to the admin group (what a real admin user's + token carries); tests pass a different value — or ``None`` for NO + groups claim at all — to prove the group gate rejects it. + """ + now = int(time.time()) + claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now} + if groups is not None: + claims["groups"] = ( + list(groups) if isinstance(groups, (list, tuple)) else groups + ) + if nbf_in is not None: + claims["nbf"] = now + nbf_in + if expires_in is not None: + claims["exp"] = now + expires_in + headers: dict[str, str] = {} + if kid is not None: + headers["kid"] = kid + return jwt.encode( + claims, signing_key, algorithm="RS256", headers=headers + ) + + +class AdminRunner: + """Run the app's lifespan around an httpx AsyncClient. + + Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI + transport does not run the lifespan, so it is driven explicitly and + shut down in ``__aexit__``'s finally block even on failure. + + The app's JWKS cache is replaced (before the lifespan runs) with + one built around a counting stub fetcher, so verification goes + through the REAL cache logic but never the network. + """ + + def __init__( + self, + db_path: str, + jwks_document: dict[str, object], + *, + ttl_seconds: float | None = None, + backoff_seconds: float | None = None, + kid_refetch_seconds: float | None = None, + ) -> None: + self.app = create_app( + ApiConfig( + db_path=db_path, + panel_token="admin-test-panel-token", + oidc_issuer=ISSUER, + oidc_audience=AUDIENCE, + oidc_jwks_url=JWKS_URL, + ) + ) + self.fetch = StubbedFetch(jwks_document) + cache_kwargs: dict[str, float] = {} + if ttl_seconds is not None: + cache_kwargs["ttl_seconds"] = ttl_seconds + if backoff_seconds is not None: + cache_kwargs["backoff_seconds"] = backoff_seconds + if kid_refetch_seconds is not None: + cache_kwargs["kid_refetch_seconds"] = kid_refetch_seconds + self.app.state.jwks_cache = JwksCache( + JWKS_URL, fetcher=self.fetch, **cache_kwargs + ) + + async def __aenter__(self) -> httpx.AsyncClient: + self._lifespan = self.app.router.lifespan_context(self.app) + await self._lifespan.__aenter__() + self._client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=self.app), + base_url="http://testserver", + ) + return await self._client.__aenter__() + + async def __aexit__(self, *exc) -> None: + try: + await self._client.__aexit__(*exc) + finally: + await self._lifespan.__aexit__(*exc) diff --git a/tests/conftest.py b/tests/conftest.py index 1e4b728..ece6b88 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,8 +33,11 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock +import httpx import pytest +from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for + # --------------------------------------------------------------------------- # Homeassistant module provisioning (explicitly mock-only). # --------------------------------------------------------------------------- @@ -953,4 +956,25 @@ def freeze_nestquest_clock( date=datetime.date, ) monkeypatch.setattr(nestquest, "datetime", fake_module) - return frozen, requested \ No newline at end of file + return frozen, requested + + +# --------------------------------------------------------------------------- +# Admin-plane API fixtures (shared by the admin-plane test modules). +# +# The local-key/stubbed-JWKS harness itself lives in +# :mod:`tests.admin_jwt_harness` (imported by name, the same pattern as +# tests.blueprint_helpers); these fixtures hand its runner to every test +# module in tests/ without re-declaring it. +# --------------------------------------------------------------------------- +@pytest.fixture +def temp_db_path(tmp_path: Path) -> str: + """A fresh per-test SQLite path under pytest's tmp_path.""" + return str(tmp_path / "nestquest.db") + + +@pytest.fixture +async def admin_client(temp_db_path: str) -> httpx.AsyncClient: + """An app client whose provider publishes the admin test key.""" + async with AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) as client: + yield client \ No newline at end of file diff --git a/tests/test_api_admin_auth.py b/tests/test_api_admin_auth.py index e1a19ad..c9dbd05 100644 --- a/tests/test_api_admin_auth.py +++ b/tests/test_api_admin_auth.py @@ -2,7 +2,10 @@ These tests exercise the NestQuest API's admin plane against the ASGI app with httpx, using a locally generated RSA key pair and a stubbed -JWKS fetcher — NO network access: +JWKS fetcher — NO network access. That harness (the key pairs, the +JWKS builder, the token minting and the app runner) is shared with the +other admin-plane route tests in :mod:`tests.admin_jwt_harness`; this +module imports it and proves the auth CONTRACT itself: - ``GET /api/v1/admin/ping`` requires a valid Authentik OIDC JWT: a request with NO ``Authorization`` header and a MALFORMED header @@ -41,28 +44,24 @@ import asyncio import base64 import json import time -from pathlib import Path import httpx -import jwt import pytest -from cryptography.hazmat.primitives.asymmetric import rsa -from api.app import create_app -from api.auth import ADMIN_GROUP, JwksCache +from api.auth import JwksCache from api.config import ApiConfig, ConfigError - -#: The configured OIDC settings the test apps run with (values a real -#: deployment would get from the NESTQUEST_OIDC_* environment vars). -ISSUER = "https://authentik.example.com/application/o/nestquest/" -AUDIENCE = "nestquest-api" -JWKS_URL = "https://authentik.example.com/application/o/nestquest/jwks/" - -#: The ``kid`` the test tokens carry, matching the JWKS entry. -KID = "admin-test-key-1" - -#: The subject a valid token authenticates as. -SUBJECT = "admin-user-1" +from tests.admin_jwt_harness import ( + ADMIN_KEY, + AUDIENCE, + ISSUER, + JWKS_URL, + KID, + OTHER_KEY, + SUBJECT, + AdminRunner as _AdminRunner, + jwks_for as _jwks_for, + make_token as _make_token, +) #: The uniform 401 detail every rejection must carry. REJECTION_DETAIL = "Missing or invalid admin credentials" @@ -78,53 +77,6 @@ FORBIDDEN_DETAIL = ( ) -def _generate_key() -> rsa.RSAPrivateKey: - """Generate one local RSA key pair (no network, no fixtures).""" - return rsa.generate_private_key(public_exponent=65537, key_size=2048) - - -#: The key pair whose PUBLIC half the stubbed JWKS publishes — tokens -#: signed with it verify; the second pair below proves they don't when -#: signed otherwise. -ADMIN_KEY = _generate_key() -OTHER_KEY = _generate_key() - - -def _b64url_uint(value: int) -> str: - """Base64url-encode a non-negative integer (no padding), per JWK.""" - raw = value.to_bytes((value.bit_length() + 7) // 8, "big") - return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") - - -def _jwks_for(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, object]: - """Build a JWKS document publishing ``private_key``'s public half.""" - numbers = private_key.public_key().public_numbers() - return { - "keys": [ - { - "kty": "RSA", - "use": "sig", - "alg": "RS256", - "kid": kid, - "n": _b64url_uint(numbers.n), - "e": _b64url_uint(numbers.e), - } - ] - } - - -class _StubbedFetch: - """A JWKS fetcher stub: returns a fixed document, counts calls.""" - - def __init__(self, document: dict[str, object]) -> None: - self.document = document - self.calls = 0 - - async def __call__(self, url: str) -> dict[str, object]: - self.calls += 1 - return self.document - - class _FailingFetch: """A JWKS fetcher stub that always fails, counting the attempts.""" @@ -164,41 +116,6 @@ class _FailThenServe: return self.document -def _make_token( - signing_key: rsa.RSAPrivateKey = ADMIN_KEY, - *, - kid: str | None = KID, - iss: str = ISSUER, - aud: object = AUDIENCE, - sub: str = SUBJECT, - expires_in: int | None = 300, - nbf_in: int | None = -1, - groups: object = (ADMIN_GROUP,), -) -> str: - """Mint one RS256 JWT the way Authentik would (its claims, our key). - - ``groups`` defaults to the admin group (what a real admin user's - token carries); tests pass a different value — or ``None`` for NO - groups claim at all — to prove the group gate rejects it. - """ - now = int(time.time()) - claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now} - if groups is not None: - claims["groups"] = ( - list(groups) if isinstance(groups, (list, tuple)) else groups - ) - if nbf_in is not None: - claims["nbf"] = now + nbf_in - if expires_in is not None: - claims["exp"] = now + expires_in - headers: dict[str, str] = {} - if kid is not None: - headers["kid"] = kid - return jwt.encode( - claims, signing_key, algorithm="RS256", headers=headers - ) - - def _token_with_header( header: dict[str, object], payload: dict[str, object] ) -> str: @@ -226,79 +143,6 @@ def _verified_payload() -> dict[str, object]: } -class _AdminRunner: - """Run the app's lifespan around an httpx AsyncClient. - - Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI - transport does not run the lifespan, so it is driven explicitly and - shut down in ``__aexit__``'s finally block even on failure. - - The app's JWKS cache is replaced (before the lifespan runs) with - one built around a counting stub fetcher, so verification goes - through the REAL cache logic but never the network. - """ - - def __init__( - self, - db_path: str, - jwks_document: dict[str, object], - *, - ttl_seconds: float | None = None, - backoff_seconds: float | None = None, - kid_refetch_seconds: float | None = None, - ) -> None: - self.app = create_app( - ApiConfig( - db_path=db_path, - panel_token="admin-test-panel-token", - oidc_issuer=ISSUER, - oidc_audience=AUDIENCE, - oidc_jwks_url=JWKS_URL, - ) - ) - self.fetch = _StubbedFetch(jwks_document) - cache_kwargs: dict[str, float] = {} - if ttl_seconds is not None: - cache_kwargs["ttl_seconds"] = ttl_seconds - if backoff_seconds is not None: - cache_kwargs["backoff_seconds"] = backoff_seconds - if kid_refetch_seconds is not None: - cache_kwargs["kid_refetch_seconds"] = kid_refetch_seconds - self.app.state.jwks_cache = JwksCache( - JWKS_URL, fetcher=self.fetch, **cache_kwargs - ) - - async def __aenter__(self) -> httpx.AsyncClient: - self._lifespan = self.app.router.lifespan_context(self.app) - await self._lifespan.__aenter__() - self._client = httpx.AsyncClient( - transport=httpx.ASGITransport(app=self.app), - base_url="http://testserver", - ) - return await self._client.__aenter__() - - async def __aexit__(self, *exc) -> None: - try: - await self._client.__aexit__(*exc) - finally: - await self._lifespan.__aexit__(*exc) - - -@pytest.fixture -def temp_db_path(tmp_path: Path) -> str: - """A fresh per-test SQLite path under pytest's tmp_path.""" - return str(tmp_path / "nestquest.db") - - -@pytest.fixture -async def admin_client( - temp_db_path: str, -) -> httpx.AsyncClient: - """An app client whose provider publishes the admin test key.""" - async with _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) as client: - yield client - - # --- the happy path ------------------------------------------------------- diff --git a/tests/test_api_admin_children.py b/tests/test_api_admin_children.py new file mode 100644 index 0000000..248f049 --- /dev/null +++ b/tests/test_api_admin_children.py @@ -0,0 +1,464 @@ +"""Admin-plane children route tests (task e4845baa). + +These tests exercise the admin plane's children routes against the +ASGI app with httpx, reusing the shared local-RSA-key/stubbed-JWKS +harness (:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely +authenticates — signature, issuer, audience, expiry and the +nestquest-admins group — with NO network access. + +Every done-condition case is proven against the DATABASE, read through +the SAME core copy the app uses (``nestquest_core.*``; see the two-copy +caveat in api/nestquest_core.py): + +- Authentication runs through the router's ONE ``require_admin`` + dependency: an admin JWT passes, a JWT without the group is 403, an + absent credential is 401 — and no row is written. +- ``POST /api/v1/admin/children``: the created child EXISTS with the + given display name (and the optional colour/avatar_ref/sort_order). +- ``PATCH /api/v1/admin/children/{id}``: an edit PERSISTS the supplied + fields and leaves omitted fields untouched (exclude_unset semantics — + an omitted field is never passed to the core). +- ``PATCH /api/v1/admin/children/{id}/active`` with ``is_active: false`` + sets the active flag FALSE and the row STILL EXISTS (children are + never hard-deleted). +- ``POST /api/v1/admin/children/reorder``: the posted order becomes the + rows' sort_order; a list that is not a complete permutation is 422 + and leaves the stored order untouched. + +Error mapping (documented in api/routes_admin.py): a core ValueError +for a non-existent child is 404; every other core ValueError (an empty +display name, an explicit null colour, a bad permutation) is 422 — the +same failure class FastAPI reports for malformed bodies. +""" +from __future__ import annotations + +import importlib +from types import SimpleNamespace + +import httpx +import pytest + +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +# The children business layer through the SAME core copy the app uses +# (nestquest_core.*, NOT the conftest-stubbed custom_components copy) — +# used for the tests' database assertions only; the routes reach it +# through api.nestquest_core. +_children = importlib.import_module("nestquest_core.children") + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +async def _children_by_id(database: object) -> dict[int, object]: + """Every child row through the app's core copy, keyed by id.""" + records = await _children.list_children(database) + return {record.id: record for record in records} + + +@pytest.fixture +async def admin_children(temp_db_path: str) -> SimpleNamespace: + """An admin-plane app client plus its live database. + + The same runner the auth tests use (stubbed JWKS, no network); the + fixture also exposes the app's ONE database connection so tests + assert the routes' effects on the stored rows themselves. + """ + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield SimpleNamespace( + client=client, + database=runner.app.state.db.database, + ) + + +# --- authentication: the ONE require_admin dependency guards every route --- + + +async def test_children_routes_refuse_non_admin_and_absent_credentials( + admin_children: SimpleNamespace, +) -> None: + """A non-admin JWT is 403, an absent credential is 401: nothing written.""" + client = admin_children.client + body = {"display_name": "Ada"} + non_admin = make_token(groups=("some-other-group",)) + + forbidden = await client.post( + "/api/v1/admin/children", + json=body, + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert forbidden.status_code == 403 + assert forbidden.json()["detail"] == ( + "Admin access requires membership in the nestquest-admins group" + ) + + unauthenticated = await client.post("/api/v1/admin/children", json=body) + assert unauthenticated.status_code == 401 + + unauthenticated_list = await client.get("/api/v1/admin/children") + assert unauthenticated_list.status_code == 401 + + # No route ran its handler: the household is still empty. + assert await _children_by_id(admin_children.database) == {} + + +# --- create ----------------------------------------------------------------- + + +async def test_create_child_persists_the_given_name( + admin_children: SimpleNamespace, +) -> None: + """POST /children creates the row; the child exists with that name.""" + response = await admin_children.client.post( + "/api/v1/admin/children", + json={ + "display_name": "Ada", + "colour": "#ff8800", + "avatar_ref": "avatar:bee", + "sort_order": 2, + }, + headers=_admin_headers(), + ) + assert response.status_code == 201 + body = response.json() + assert body["display_name"] == "Ada" + assert body["colour"] == "#ff8800" + assert body["avatar_ref"] == "avatar:bee" + assert body["sort_order"] == 2 + assert body["is_active"] is True + + stored = (await _children_by_id(admin_children.database))[body["id"]] + assert stored.display_name == "Ada" + assert stored.colour == "#ff8800" + assert stored.avatar_ref == "avatar:bee" + assert stored.sort_order == 2 + assert stored.is_active is True + + +async def test_create_child_with_only_a_name_uses_core_defaults( + admin_children: SimpleNamespace, +) -> None: + """Omitted optional fields default through the core layer.""" + response = await admin_children.client.post( + "/api/v1/admin/children", + json={"display_name": "Bo"}, + headers=_admin_headers(), + ) + assert response.status_code == 201 + stored = (await _children_by_id(admin_children.database))[ + response.json()["id"] + ] + assert stored.display_name == "Bo" + assert stored.colour is None + assert stored.avatar_ref is None + assert stored.sort_order == 0 + assert stored.is_active is True + + +@pytest.mark.parametrize( + "body", + [ + {}, # display_name missing + {"display_name": 5}, # wrong type + {"display_name": ""}, # empty: the core rejects it + {"display_name": " "}, # whitespace-only: empty to a parent + {"display_name": "Ada", "sort_order": "first"}, # wrong type + ], +) +async def test_create_child_malformed_input_is_422( + admin_children: SimpleNamespace, body: dict[str, object] +) -> None: + """Malformed bodies are 422 and never create a row.""" + response = await admin_children.client.post( + "/api/v1/admin/children", json=body, headers=_admin_headers() + ) + assert response.status_code == 422 + assert await _children_by_id(admin_children.database) == {} + + +# --- edit ------------------------------------------------------------------- + + +async def test_edit_child_persists_supplied_fields_and_skips_omitted_ones( + admin_children: SimpleNamespace, +) -> None: + """PATCH persists exactly the supplied fields (exclude_unset).""" + client = admin_children.client + created = await client.post( + "/api/v1/admin/children", + json={ + "display_name": "Ada", + "colour": "#ff8800", + "avatar_ref": "avatar:bee", + }, + headers=_admin_headers(), + ) + assert created.status_code == 201 + child_id = created.json()["id"] + + # Only display_name and sort_order: colour/avatar_ref must survive. + edited = await client.patch( + f"/api/v1/admin/children/{child_id}", + json={"display_name": "Ada Lovelace", "sort_order": 4}, + headers=_admin_headers(), + ) + assert edited.status_code == 200 + assert edited.json()["display_name"] == "Ada Lovelace" + assert edited.json()["sort_order"] == 4 + stored = (await _children_by_id(admin_children.database))[child_id] + assert stored.display_name == "Ada Lovelace" + assert stored.sort_order == 4 + # The omitted fields were never passed to the core: unchanged. + assert stored.colour == "#ff8800" + assert stored.avatar_ref == "avatar:bee" + + # A second edit touching only colour leaves the name alone. + recoloured = await client.patch( + f"/api/v1/admin/children/{child_id}", + json={"colour": "#00aa55"}, + headers=_admin_headers(), + ) + assert recoloured.status_code == 200 + stored = (await _children_by_id(admin_children.database))[child_id] + assert stored.colour == "#00aa55" + assert stored.display_name == "Ada Lovelace" + + +async def test_edit_unknown_child_is_404( + admin_children: SimpleNamespace, +) -> None: + """An edit naming a child that does not exist is 404.""" + response = await admin_children.client.patch( + "/api/v1/admin/children/999", + json={"display_name": "Nobody"}, + headers=_admin_headers(), + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Child not found" + + +async def test_edit_explicit_null_colour_is_422_and_changes_nothing( + admin_children: SimpleNamespace, +) -> None: + """An explicit null (clearing) is passed through and rejected by the core. + + Only the fields the request SUPPLIES are forwarded: a null colour is + a supplied None, which the core refuses (the DAO cannot express + NULL) — mapped to 422, with the stored colour left untouched. + """ + created = await admin_children.client.post( + "/api/v1/admin/children", + json={"display_name": "Ada", "colour": "#ff8800"}, + headers=_admin_headers(), + ) + child_id = created.json()["id"] + response = await admin_children.client.patch( + f"/api/v1/admin/children/{child_id}", + json={"colour": None}, + headers=_admin_headers(), + ) + assert response.status_code == 422 + stored = (await _children_by_id(admin_children.database))[child_id] + assert stored.colour == "#ff8800" + + +# --- set_active ------------------------------------------------------------- + + +async def test_deactivation_sets_the_flag_false_and_never_deletes( + admin_children: SimpleNamespace, +) -> None: + """is_active=false deactivates the row; the row STILL exists.""" + client = admin_children.client + created = await client.post( + "/api/v1/admin/children", + json={"display_name": "Ada"}, + headers=_admin_headers(), + ) + child_id = created.json()["id"] + + deactivated = await client.patch( + f"/api/v1/admin/children/{child_id}/active", + json={"is_active": False}, + headers=_admin_headers(), + ) + assert deactivated.status_code == 200 + assert deactivated.json()["is_active"] is False + + # Database proof: the flag is False and the row was NOT hard-deleted. + rows = await _children_by_id(admin_children.database) + stored = rows[child_id] + assert stored.is_active is False + assert stored.display_name == "Ada" + + # Reactivation goes through the same route: the flag is set, not + # one-way. + reactivated = await client.patch( + f"/api/v1/admin/children/{child_id}/active", + json={"is_active": True}, + headers=_admin_headers(), + ) + assert reactivated.status_code == 200 + assert reactivated.json()["is_active"] is True + rows = await _children_by_id(admin_children.database) + assert rows[child_id].is_active is True + + +async def test_set_active_unknown_child_is_404( + admin_children: SimpleNamespace, +) -> None: + """A set_active naming a child that does not exist is 404.""" + response = await admin_children.client.patch( + "/api/v1/admin/children/999/active", + json={"is_active": False}, + headers=_admin_headers(), + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Child not found" + + +async def test_set_active_malformed_body_is_422( + admin_children: SimpleNamespace, +) -> None: + """A non-bool is_active is rejected by the body model (422).""" + response = await admin_children.client.patch( + "/api/v1/admin/children/1/active", + json={"is_active": "maybe"}, + headers=_admin_headers(), + ) + assert response.status_code == 422 + + +# --- reorder ---------------------------------------------------------------- + + +async def test_reorder_writes_the_posted_order_to_sort_order( + admin_children: SimpleNamespace, +) -> None: + """The posted id order becomes the rows' sort_order.""" + client = admin_children.client + ids = [] + for name in ("Ada", "Bo", "Cyd"): + created = await client.post( + "/api/v1/admin/children", + json={"display_name": name}, + headers=_admin_headers(), + ) + assert created.status_code == 201 + ids.append(created.json()["id"]) + + response = await client.post( + "/api/v1/admin/children/reorder", + json={"ordered_ids": [ids[2], ids[0], ids[1]]}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + # Database proof: each posted position became that child's sort_order. + rows = await _children_by_id(admin_children.database) + assert rows[ids[2]].sort_order == 0 + assert rows[ids[0]].sort_order == 1 + assert rows[ids[1]].sort_order == 2 + + +async def test_reorder_rejects_non_permutations_and_writes_nothing( + admin_children: SimpleNamespace, +) -> None: + """A list that is not a complete permutation is 422, order intact. + + The core rejects the list BEFORE any write, so the stored sort_order + values survive every rejected call unchanged. + """ + client = admin_children.client + ids = [] + for name in ("Ada", "Bo"): + created = await client.post( + "/api/v1/admin/children", + json={"display_name": name}, + headers=_admin_headers(), + ) + assert created.status_code == 201 + ids.append(created.json()["id"]) + + bad_lists = [ + ids[:1], # partial: missing an existing child + [ids[0], ids[0], ids[1]], # duplicate id + [*ids, ids[1] + 999], # unknown id + ] + for ordered_ids in bad_lists: + response = await client.post( + "/api/v1/admin/children/reorder", + json={"ordered_ids": ordered_ids}, + headers=_admin_headers(), + ) + assert response.status_code == 422, ordered_ids + + # A non-integer id never reaches the core either (body model). + malformed = await client.post( + "/api/v1/admin/children/reorder", + json={"ordered_ids": ["first", "second"]}, + headers=_admin_headers(), + ) + assert malformed.status_code == 422 + + # Database proof: no write happened for any rejected call. + rows = await _children_by_id(admin_children.database) + assert [rows[child_id].sort_order for child_id in ids] == [0, 0] + + +# --- list (the read model the admin PWA re-renders from) --------------------- + + +async def test_list_children_returns_all_in_display_order( + admin_children: SimpleNamespace, +) -> None: + """GET /children returns every child (inactive included) in order.""" + client = admin_children.client + ada = await client.post( + "/api/v1/admin/children", + json={"display_name": "Ada", "sort_order": 5}, + headers=_admin_headers(), + ) + bo = await client.post( + "/api/v1/admin/children", + json={"display_name": "Bo", "sort_order": 1}, + headers=_admin_headers(), + ) + deactivated = await client.patch( + f"/api/v1/admin/children/{ada.json()['id']}/active", + json={"is_active": False}, + headers=_admin_headers(), + ) + assert deactivated.status_code == 200 + + response = await client.get( + "/api/v1/admin/children", headers=_admin_headers() + ) + assert response.status_code == 200 + children = response.json()["children"] + assert [child["id"] for child in children] == [ + bo.json()["id"], + ada.json()["id"], + ] + by_id = {child["id"]: child for child in children} + assert by_id[ada.json()["id"]]["is_active"] is False + assert by_id[bo.json()["id"]]["is_active"] is True + # The serialized shape is the documented child payload. + assert set(by_id[bo.json()["id"]]) == { + "id", + "display_name", + "colour", + "avatar_ref", + "sort_order", + "is_active", + }