TZ-002: with no zone stored the API reads its clock in the host's local time, but the panel fell back to Home Assistant's zone and the admin app to the browser's, so dates could disagree near midnight. The API now names the zone it actually uses (api/host_zone.py: the stored zone, else the host's own IANA zone from TZ, /etc/localtime or /etc/timezone) and publishes it as the panel snapshot's `timezone` and the admin settings' read-only `effective_timezone`. Both cards and the admin app format in that one zone; Home Assistant's or the browser's zone applies only when none is published. TZ-003: snapshot_from_api_payload reads `timezone` with a "" default, so an integration upgraded ahead of its API keeps refreshing against the pre-change payload. Task 7e173881-fb99-426f-a2f0-2678439ffc39 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
334 lines
12 KiB
Python
334 lines
12 KiB
Python
"""Admin-plane snapshot route tests (task ef3f4877).
|
|
|
|
These tests exercise ``GET /api/v1/admin/snapshot`` 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 with NO network access. The household is seeded with
|
|
:func:`tests.test_api_panel._seed_household` through the SAME core
|
|
copy the app uses (``nestquest_core.*``), and both routes' single
|
|
clock read (``_local_now``) is pinned to noon so nothing straddles
|
|
midnight.
|
|
|
|
Proven per the done-condition:
|
|
|
|
- An admin JWT returns 200 with the documented shape: ``today_iso``,
|
|
``cycle_day`` and, per child, the presence fields, the rollup counts
|
|
and ``instances`` in the core ``instance_payload`` shape.
|
|
- A MISSED instance is KEPT in the admin payload (D-009) and still
|
|
OMITTED from the panel payload. The core builder lists only
|
|
instances due on ``now.date()``, and ``derive_state`` calls an
|
|
instance missed only when its due date is BEFORE that day — so a
|
|
real snapshot never carries a missed view. The test therefore
|
|
wraps the real builder to add the seeded past-due instance as a
|
|
view whose state the core's own ``derive_state`` computes
|
|
(``missed``), and reads it through both routes.
|
|
- Auth runs through the router's ONE ``require_admin`` dependency: no
|
|
credential is 401, the panel service token is refused outright
|
|
(403, the credential-type detail), and a non-admin JWT is 403.
|
|
- The handler performs exactly ONE clock read per request.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import datetime
|
|
import importlib
|
|
from types import SimpleNamespace
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
|
|
from api import host_zone, routes_admin, routes_panel
|
|
from api.nestquest_core import core_snapshot
|
|
from tests.admin_jwt_harness import (
|
|
ADMIN_KEY,
|
|
KID,
|
|
AdminRunner,
|
|
jwks_for,
|
|
make_token,
|
|
)
|
|
from tests.test_api_panel import _seed_household
|
|
|
|
# The completion layer through the SAME core copy the app uses — the
|
|
# missed test derives its view's state with the core's own rule.
|
|
_completion = importlib.import_module("nestquest_core.completion")
|
|
|
|
#: The panel service token the harness app is configured with.
|
|
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
|
|
|
#: The 403 detail for the panel service token on an admin route.
|
|
PANEL_TOKEN_REJECTION_DETAIL = (
|
|
"This credential type is not accepted for admin access"
|
|
)
|
|
|
|
#: The 403 detail for a valid JWT without the admin group.
|
|
FORBIDDEN_DETAIL = (
|
|
"Admin access requires membership in the nestquest-admins group"
|
|
)
|
|
|
|
|
|
def _admin_headers() -> dict[str, str]:
|
|
"""The Authorization header of a valid nestquest-admins JWT."""
|
|
return {"Authorization": f"Bearer {make_token()}"}
|
|
|
|
|
|
def _panel_headers() -> dict[str, str]:
|
|
"""The Authorization header carrying the harness panel token."""
|
|
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
|
|
|
|
|
|
@pytest.fixture
|
|
async def snapshot_household(
|
|
temp_db_path: str, monkeypatch
|
|
) -> SimpleNamespace:
|
|
"""A seeded household served by the admin-plane app, clock pinned.
|
|
|
|
``today`` comes from one host clock read; the pinned instant is
|
|
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). Both
|
|
the admin and the panel route's ``_local_now`` return it, and the
|
|
admin one counts its calls.
|
|
"""
|
|
today = datetime.datetime.now().astimezone().date()
|
|
pinned_now = datetime.datetime(
|
|
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
|
|
)
|
|
seed = await _seed_household(temp_db_path, today, pinned_now)
|
|
|
|
clock_reads: list[datetime.datetime] = []
|
|
|
|
def _admin_now() -> datetime.datetime:
|
|
clock_reads.append(pinned_now)
|
|
return pinned_now
|
|
|
|
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
|
|
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
|
|
# The API host runs UTC, the zone of the pinned clock.
|
|
monkeypatch.setattr(host_zone, "host_timezone", lambda: "UTC")
|
|
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
|
async with runner as client:
|
|
yield SimpleNamespace(
|
|
client=client,
|
|
seed=seed,
|
|
today=today,
|
|
pinned_now=pinned_now,
|
|
pinned_utc_iso=pinned_now.astimezone(
|
|
datetime.timezone.utc
|
|
).isoformat(),
|
|
clock_reads=clock_reads,
|
|
)
|
|
|
|
|
|
# --- authentication: the ONE require_admin dependency ----------------------
|
|
|
|
|
|
async def test_snapshot_without_credential_is_401(
|
|
snapshot_household: SimpleNamespace,
|
|
) -> None:
|
|
"""No Authorization header is the uniform 401."""
|
|
response = await snapshot_household.client.get("/api/v1/admin/snapshot")
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_snapshot_refuses_panel_service_token_outright(
|
|
snapshot_household: SimpleNamespace,
|
|
) -> None:
|
|
"""The panel service token is refused as a credential type (403).
|
|
|
|
403 with the credential-type detail — not the uniform 401 and not
|
|
the non-admin group detail — proves the token was refused outright,
|
|
never tried as a JWT.
|
|
"""
|
|
response = await snapshot_household.client.get(
|
|
"/api/v1/admin/snapshot", headers=_panel_headers()
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
|
|
|
|
|
async def test_snapshot_refuses_non_admin_jwt(
|
|
snapshot_household: SimpleNamespace,
|
|
) -> None:
|
|
"""A valid JWT without the nestquest-admins group is 403."""
|
|
non_admin = make_token(groups=("some-other-group",))
|
|
response = await snapshot_household.client.get(
|
|
"/api/v1/admin/snapshot",
|
|
headers={"Authorization": f"Bearer {non_admin}"},
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
|
|
|
|
|
# --- the payload -----------------------------------------------------------
|
|
|
|
|
|
async def test_admin_jwt_returns_documented_snapshot(
|
|
snapshot_household: SimpleNamespace,
|
|
) -> None:
|
|
"""An admin JWT returns 200 with the documented snapshot shape.
|
|
|
|
Every field is asserted as an explicit literal, with ONE clock
|
|
read for the request. With no missed view in the real snapshot,
|
|
the admin body equals the panel body for the same household — the
|
|
panel body additionally carries the effective ``timezone`` its cards
|
|
format the visible date and clock in (none stored here: the UTC
|
|
host's own zone).
|
|
"""
|
|
client = snapshot_household.client
|
|
seed = snapshot_household.seed
|
|
|
|
response = await client.get(
|
|
"/api/v1/admin/snapshot", headers=_admin_headers()
|
|
)
|
|
assert response.status_code == 200
|
|
assert len(snapshot_household.clock_reads) == 1
|
|
body = response.json()
|
|
|
|
assert body == {
|
|
"today_iso": snapshot_household.today.isoformat(),
|
|
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
|
|
"cycle_day": 2,
|
|
"children": [
|
|
{
|
|
"child_id": seed.ada.id,
|
|
"child_name": "Ada",
|
|
"present": False,
|
|
"next_present": seed.returns_date.isoformat(),
|
|
"due_today": 1,
|
|
"completed_today": 0,
|
|
"remaining_today": 1,
|
|
"completion_pct": 0,
|
|
"instances": [
|
|
{
|
|
"id": seed.pack_bag_instance.id,
|
|
"definition_id": seed.pack_bag_instance.definition_id,
|
|
"child_id": seed.ada.id,
|
|
"title": "Pack bag",
|
|
"icon": None,
|
|
"window": "morning",
|
|
"due_time": "10:00",
|
|
"state": "open",
|
|
"overdue": True,
|
|
"completed_at": None,
|
|
"on_time": None,
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"child_id": seed.bo.id,
|
|
"child_name": "Bo",
|
|
"present": True,
|
|
"next_present": None,
|
|
"due_today": 1,
|
|
"completed_today": 1,
|
|
"remaining_today": 0,
|
|
"completion_pct": 100,
|
|
"instances": [
|
|
{
|
|
"id": seed.brush_teeth_instance.id,
|
|
"definition_id": (
|
|
seed.brush_teeth_instance.definition_id
|
|
),
|
|
"child_id": seed.bo.id,
|
|
"title": "Brush teeth",
|
|
"icon": None,
|
|
"window": "morning",
|
|
"due_time": "17:00",
|
|
"state": "completed",
|
|
"overdue": False,
|
|
"completed_at": snapshot_household.pinned_utc_iso,
|
|
"on_time": True,
|
|
}
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
panel = await client.get(
|
|
"/api/v1/panel/snapshot", headers=_panel_headers()
|
|
)
|
|
assert panel.status_code == 200
|
|
assert panel.json() == {**body, "timezone": "UTC"}
|
|
|
|
|
|
async def test_admin_keeps_missed_instance_panel_omits_it(
|
|
snapshot_household: SimpleNamespace, monkeypatch
|
|
) -> None:
|
|
"""A missed view is KEPT by the admin route, OMITTED by the panel's.
|
|
|
|
The real builder is wrapped to add the seeded past-due Stale chore
|
|
instance to Ada's views, its state derived by the core's own
|
|
``derive_state`` (no events, due yesterday => ``missed``). Both
|
|
routes read the SAME wrapped snapshot, so the only difference is
|
|
each route's ``include_missed`` flag.
|
|
"""
|
|
client = snapshot_household.client
|
|
seed = snapshot_household.seed
|
|
stale = seed.stale_instance
|
|
missed_state = _completion.derive_state(
|
|
stale, None, snapshot_household.today
|
|
)
|
|
assert missed_state == "missed"
|
|
missed_view = core_snapshot.QuestInstanceView(
|
|
instance_id=stale.id,
|
|
definition_id=stale.definition_id,
|
|
child_id=stale.child_id,
|
|
title="Stale chore",
|
|
icon=None,
|
|
window=stale.window,
|
|
due_date=stale.due_date,
|
|
due_time=stale.due_time,
|
|
state=missed_state,
|
|
overdue=False,
|
|
completed_at=None,
|
|
was_on_time=None,
|
|
)
|
|
real_build_snapshot = core_snapshot.build_snapshot
|
|
|
|
async def _with_missed_view(database, settings, now):
|
|
snapshot = await real_build_snapshot(database, settings, now)
|
|
return dataclasses.replace(
|
|
snapshot,
|
|
children=tuple(
|
|
dataclasses.replace(
|
|
child, instances=(*child.instances, missed_view)
|
|
)
|
|
if child.child_id == seed.ada.id
|
|
else child
|
|
for child in snapshot.children
|
|
),
|
|
)
|
|
|
|
monkeypatch.setattr(core_snapshot, "build_snapshot", _with_missed_view)
|
|
|
|
admin = await client.get(
|
|
"/api/v1/admin/snapshot", headers=_admin_headers()
|
|
)
|
|
assert admin.status_code == 200
|
|
admin_ada = admin.json()["children"][0]
|
|
assert admin_ada["child_id"] == seed.ada.id
|
|
assert [instance["id"] for instance in admin_ada["instances"]] == [
|
|
seed.pack_bag_instance.id,
|
|
stale.id,
|
|
]
|
|
assert admin_ada["instances"][1] == {
|
|
"id": stale.id,
|
|
"definition_id": stale.definition_id,
|
|
"child_id": seed.ada.id,
|
|
"title": "Stale chore",
|
|
"icon": None,
|
|
"window": "morning",
|
|
"due_time": "09:00",
|
|
"state": "missed",
|
|
"overdue": False,
|
|
"completed_at": None,
|
|
"on_time": None,
|
|
}
|
|
|
|
panel = await client.get(
|
|
"/api/v1/panel/snapshot", headers=_panel_headers()
|
|
)
|
|
assert panel.status_code == 200
|
|
panel_ada = panel.json()["children"][0]
|
|
assert [instance["id"] for instance in panel_ada["instances"]] == [
|
|
seed.pack_bag_instance.id
|
|
], "the panel payload must still omit missed instances"
|