Files
NestQuest/tests/test_api_admin_settings.py
T
dev_agentandClaude Opus 5.5 474c84bf35 Publish the API's effective timezone and tolerate an older snapshot payload
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>
2026-09-30 09:50:25 -04:00

530 lines
18 KiB
Python

"""Admin-plane settings route tests (task 2b3de7e5).
These tests exercise the admin plane's settings read and update 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. The stored settings
document is read back through the SAME core copy the app uses
(``nestquest_core.*``; see the two-copy caveat in api/nestquest_core.py)
via its :class:`nestquest_core.dao_meta.MetaStateDao`, so persistence is
proven against the DATABASE the routes wrote, not a parallel copy.
Proven per the done-condition, everything read back through the API:
- ``GET /api/v1/admin/settings`` returns the current effective settings
— the eleven documented fields at their defaults on a fresh database,
with no ``timezone_configured`` marker.
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
NEXT read reflects them; a later partial update merges onto the
current effective settings (omitted fields keep their stored value),
and the document is stored under the well-known ``nestquest_meta_state``
key.
- An invalid value (a non-positive or boolean horizon, a non-strict
time, a non-bool toggle, a non-string target) is rejected with 422
naming the field, and the stored settings are UNCHANGED — the next
read still shows the previous value. An unknown field is rejected
the same way, also when mixed with a valid field (validate
everything before writing).
- A corrupt stored document (invalid JSON, a non-object, an invalid
field value) does not crash the service: the read falls back to the
defaults, per field where possible.
- Authentication runs through the router's ONE ``require_admin``
dependency: a non-admin JWT is 403 and an absent credential is 401
on both routes — and no refused patch writes anything.
"""
from __future__ import annotations
import importlib
import json
from types import SimpleNamespace
import pytest
from api import host_zone
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
# The meta-state DAO 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.
_dao_meta = importlib.import_module("nestquest_core.dao_meta")
#: The well-known ``nestquest_meta_state`` key the API stores its
#: settings under — asserted literally so the contract is pinned here,
#: independent of the core constant.
SETTINGS_META_KEY = "settings"
#: The API host's own zone, pinned for every test (:func:`settings_client`):
#: the ``effective_timezone`` while no zone is stored.
API_HOST_ZONE = "Australia/Perth"
#: The all-defaults effective settings a fresh database reports — the
#: documented eleven fields plus the read-only ``effective_timezone``,
#: asserted literally so the payload contract is pinned here, independent
#: of the core constants.
DEFAULT_SETTINGS = {
"horizon_days": 14,
"day_rollover_time": "00:00",
"notify_target": "",
"morning_summary_time": "08:00",
"afternoon_reminder_time": "15:00",
"end_of_day_report_time": "20:00",
"morning_summary_enabled": True,
"afternoon_reminder_enabled": True,
"end_of_day_report_enabled": True,
"celebration_enabled": True,
"timezone": "",
"effective_timezone": API_HOST_ZONE,
}
def _with_zone(zone: str) -> dict:
"""The default settings with ``zone`` stored, as the API reports them."""
return {
**DEFAULT_SETTINGS,
"timezone": zone,
"effective_timezone": zone or API_HOST_ZONE,
}
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
@pytest.fixture
async def settings_client(temp_db_path: str, monkeypatch) -> SimpleNamespace:
"""An admin-plane app client plus its live database.
The same runner the children tests use (stubbed JWKS, no network);
the fixture also exposes the app's ONE database connection so tests
can read (or corrupt) the stored settings document itself. The API
host's zone is pinned to :data:`API_HOST_ZONE`.
"""
monkeypatch.setattr(host_zone, "host_timezone", lambda: API_HOST_ZONE)
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,
)
# --- read: the effective settings, defaults on a fresh database --------
async def test_get_settings_returns_defaults_on_a_fresh_database(
settings_client: SimpleNamespace,
) -> None:
"""GET returns the eleven documented fields at their defaults."""
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == DEFAULT_SETTINGS
# --- update: persists, and the NEXT read reflects it --------------------
async def test_update_persists_and_the_next_read_reflects_it(
settings_client: SimpleNamespace,
) -> None:
"""PATCH persists the supplied fields; GET reads them back."""
client = settings_client.client
changes = {
"horizon_days": 21,
"day_rollover_time": "03:30",
"notify_target": "notify.mobile_app_dads_phone",
"celebration_enabled": False,
}
patched = await client.patch(
"/api/v1/admin/settings", json=changes, headers=_admin_headers()
)
assert patched.status_code == 200
expected = {**DEFAULT_SETTINGS, **changes}
assert patched.json() == expected
# The NEXT read reflects the persisted settings...
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.status_code == 200
assert reread.json() == expected
# ...and the DATABASE holds the merged document: the complete twelve
# fields, keyed by field name, under the well-known meta_state key.
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is not None
stored = dict(expected)
del stored["effective_timezone"] # derived on read, never stored
assert json.loads(raw) == stored
async def test_partial_update_merges_onto_the_current_settings(
settings_client: SimpleNamespace,
) -> None:
"""A later PATCH changes only its own fields; the rest survive."""
client = settings_client.client
first = await client.patch(
"/api/v1/admin/settings",
json={"horizon_days": 7, "notify_target": " notify.x "},
headers=_admin_headers(),
)
assert first.status_code == 200
# The notify target went through the core's own normalizer: stripped.
assert first.json()["horizon_days"] == 7
assert first.json()["notify_target"] == "notify.x"
second = await client.patch(
"/api/v1/admin/settings",
json={"morning_summary_time": "07:45"},
headers=_admin_headers(),
)
assert second.status_code == 200
assert second.json() == {
**DEFAULT_SETTINGS,
"horizon_days": 7,
"notify_target": "notify.x",
"morning_summary_time": "07:45",
}
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json() == second.json()
async def test_empty_update_is_a_noop(
settings_client: SimpleNamespace,
) -> None:
"""PATCHing no fields leaves the effective settings as they are."""
client = settings_client.client
response = await client.patch(
"/api/v1/admin/settings", json={}, headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == DEFAULT_SETTINGS
async def test_null_resets_a_field_to_its_default(
settings_client: SimpleNamespace,
) -> None:
"""A JSON ``null`` maps to the field's default (None-as-absent)."""
client = settings_client.client
set_horizon = await client.patch(
"/api/v1/admin/settings",
json={"horizon_days": 7},
headers=_admin_headers(),
)
assert set_horizon.status_code == 200
assert set_horizon.json()["horizon_days"] == 7
reset = await client.patch(
"/api/v1/admin/settings",
json={"horizon_days": None},
headers=_admin_headers(),
)
assert reset.status_code == 200
assert reset.json()["horizon_days"] == 14
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json()["horizon_days"] == 14
async def test_default_settings_carry_no_timezone_configured_marker(
settings_client: SimpleNamespace,
) -> None:
"""The removed auto-set marker is absent from the default response."""
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert "timezone_configured" not in response.json()
assert response.json()["timezone"] == ""
@pytest.mark.parametrize("zone", ["America/New_York", ""])
async def test_manual_timezone_patch_persists_and_is_returned(
settings_client: SimpleNamespace, zone: str
) -> None:
"""An admin's timezone PATCH is stored exactly as given and read back."""
client = settings_client.client
seeded = await client.patch(
"/api/v1/admin/settings",
json={"timezone": "Europe/Dublin"},
headers=_admin_headers(),
)
assert seeded.status_code == 200
patched = await client.patch(
"/api/v1/admin/settings",
json={"timezone": zone},
headers=_admin_headers(),
)
assert patched.status_code == 200
expected = _with_zone(zone)
assert patched.json() == expected
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json() == expected
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is not None
stored = json.loads(raw)
assert stored["timezone"] == zone
assert "timezone_configured" not in stored
@pytest.mark.parametrize("zone", ["America/Los_Angeles", ""])
async def test_timezone_alone_sticks_on_a_fresh_database(
settings_client: SimpleNamespace, zone: str
) -> None:
"""A lone ``timezone`` PATCH needs no other field to persist."""
client = settings_client.client
patched = await client.patch(
"/api/v1/admin/settings",
json={"timezone": zone},
headers=_admin_headers(),
)
assert patched.status_code == 200
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json() == _with_zone(zone)
async def test_timezone_configured_is_an_unknown_field(
settings_client: SimpleNamespace,
) -> None:
"""The removed marker is rejected like any unknown field; nothing is written."""
response = await settings_client.client.patch(
"/api/v1/admin/settings",
json={"timezone_configured": True},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "timezone_configured" in str(response.json()["detail"])
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is None
async def test_stored_legacy_timezone_configured_key_is_ignored(
settings_client: SimpleNamespace,
) -> None:
"""A document written before the marker was removed still loads."""
await _dao_meta.MetaStateDao(settings_client.database).set(
SETTINGS_META_KEY,
json.dumps(
{"timezone": "America/Chicago", "timezone_configured": True}
),
)
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == _with_zone("America/Chicago")
# --- validation: rejected values never change the stored settings -------
@pytest.mark.parametrize(
("changes", "field"),
[
({"horizon_days": 0}, "horizon_days"),
({"horizon_days": -3}, "horizon_days"),
# A bool is rejected — the core's guardrail survives the route.
({"horizon_days": True}, "horizon_days"),
({"horizon_days": "soon"}, "horizon_days"),
({"day_rollover_time": "25:00"}, "day_rollover_time"),
({"day_rollover_time": "7:30"}, "day_rollover_time"),
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
({"celebration_enabled": 1}, "celebration_enabled"),
({"notify_target": 5}, "notify_target"),
({"timezone": "Mars/Olympus_Mons"}, "timezone"),
({"timezone": "America/New York"}, "timezone"),
({"timezone": 5}, "timezone"),
],
)
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
settings_client: SimpleNamespace, changes: dict[str, object], field: str
) -> None:
"""A rejected value is 422 naming the field; the next read is same."""
client = settings_client.client
seeded = await client.patch(
"/api/v1/admin/settings",
json={
"horizon_days": 21,
"day_rollover_time": "01:30",
"morning_summary_enabled": False,
},
headers=_admin_headers(),
)
assert seeded.status_code == 200
before = seeded.json()
response = await client.patch(
"/api/v1/admin/settings", json=changes, headers=_admin_headers()
)
assert response.status_code == 422, changes
assert field in str(response.json()["detail"]), changes
after = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert after.status_code == 200
assert after.json() == before
async def test_unknown_field_is_rejected_and_stored_settings_unchanged(
settings_client: SimpleNamespace,
) -> None:
"""An unknown field is 422 naming it — alone or beside a valid one."""
client = settings_client.client
unknown = await client.patch(
"/api/v1/admin/settings",
json={"bogus_setting": 1},
headers=_admin_headers(),
)
assert unknown.status_code == 422
assert "bogus_setting" in str(unknown.json()["detail"])
mixed = await client.patch(
"/api/v1/admin/settings",
json={"horizon_days": 7, "bogus_setting": 1},
headers=_admin_headers(),
)
assert mixed.status_code == 422
assert "bogus_setting" in str(mixed.json()["detail"])
# Neither rejection wrote anything, not even the valid sibling.
after = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert after.json() == DEFAULT_SETTINGS
async def test_non_object_body_is_422(
settings_client: SimpleNamespace,
) -> None:
"""A body that is not a JSON object is FastAPI's 422 (the shape)."""
client = settings_client.client
for body in ([1, 2], "nope", 5):
response = await client.patch(
"/api/v1/admin/settings", json=body, headers=_admin_headers()
)
assert response.status_code == 422, body
# A bare JSON null is likewise not an object.
response = await client.patch(
"/api/v1/admin/settings",
content=b"null",
headers={**_admin_headers(), "content-type": "application/json"},
)
assert response.status_code == 422
after = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert after.json() == DEFAULT_SETTINGS
# --- resilience: a corrupt stored document does not crash the service ---
async def test_corrupt_stored_document_falls_back_to_defaults(
settings_client: SimpleNamespace,
) -> None:
"""GET degrades gracefully over an unusable settings document."""
client = settings_client.client
dao = _dao_meta.MetaStateDao(settings_client.database)
# Not JSON at all: all defaults, no crash.
await dao.set(SETTINGS_META_KEY, "{not-json")
response = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == DEFAULT_SETTINGS
# JSON but not an object: all defaults, no crash.
await dao.set(SETTINGS_META_KEY, json.dumps([1, 2]))
response = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == DEFAULT_SETTINGS
# An object with one invalid field: PER-FIELD fallback — the valid
# sibling survives, the invalid one falls back to its default.
await dao.set(
SETTINGS_META_KEY,
json.dumps({"horizon_days": 7, "day_rollover_time": "soon"}),
)
response = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {**DEFAULT_SETTINGS, "horizon_days": 7}
# --- authentication: the ONE require_admin dependency guards both routes
async def test_settings_routes_refuse_non_admin_and_absent_credentials(
settings_client: SimpleNamespace,
) -> None:
"""A non-admin JWT is 403, an absent credential is 401; none write."""
client = settings_client.client
non_admin = make_token(groups=("some-other-group",))
forbidden_get = await client.get(
"/api/v1/admin/settings",
headers={"Authorization": f"Bearer {non_admin}"},
)
assert forbidden_get.status_code == 403
assert forbidden_get.json()["detail"] == (
"Admin access requires membership in the nestquest-admins group"
)
forbidden_patch = await client.patch(
"/api/v1/admin/settings",
json={"horizon_days": 7},
headers={"Authorization": f"Bearer {non_admin}"},
)
assert forbidden_patch.status_code == 403
absent_get = await client.get("/api/v1/admin/settings")
assert absent_get.status_code == 401
absent_patch = await client.patch(
"/api/v1/admin/settings", json={"horizon_days": 7}
)
assert absent_patch.status_code == 401
forbidden_zone = await client.patch(
"/api/v1/admin/settings",
json={"timezone": "America/New_York"},
headers={"Authorization": f"Bearer {non_admin}"},
)
assert forbidden_zone.status_code == 403
# No refused request changed anything: the settings are untouched.
after = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert after.json() == DEFAULT_SETTINGS