Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
530 lines
18 KiB
Python
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
|