Files
NestQuest/tests/test_api_admin_settings.py
T

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