Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
300 lines
9.8 KiB
Python
300 lines
9.8 KiB
Python
"""Admin-plane quest-definition LIST route tests (task d4667573).
|
|
|
|
``GET /api/v1/admin/quest-definitions`` 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.
|
|
|
|
- An admin JWT lists EVERY definition — active AND inactive — in rising
|
|
id order, each in the same documented shape as the create/edit
|
|
responses (id, title, description, icon, is_active, rule, assignees,
|
|
windows).
|
|
- An empty household lists nothing: 200 with an empty list.
|
|
- Authentication runs through the router's ONE ``require_admin``
|
|
dependency: an absent credential is 401, the panel service token is
|
|
refused outright (403, the credential-type detail), and a JWT without
|
|
the nestquest-admins group is 403.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from tests.admin_jwt_harness import (
|
|
ADMIN_KEY,
|
|
KID,
|
|
AdminRunner,
|
|
jwks_for,
|
|
make_token,
|
|
)
|
|
|
|
#: The admin list route under test.
|
|
LIST_PATH = "/api/v1/admin/quest-definitions"
|
|
|
|
#: 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()}"}
|
|
|
|
|
|
@pytest.fixture
|
|
async def admin_client(temp_db_path: str) -> SimpleNamespace:
|
|
"""An admin-plane app client over a fresh (empty) household."""
|
|
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
|
async with runner as client:
|
|
yield client
|
|
|
|
|
|
async def _seed_active_and_inactive(client: object) -> tuple[int, int, int]:
|
|
"""Seed one child, an ACTIVE definition and an INACTIVE one.
|
|
|
|
Returns ``(child_id, active_id, inactive_id)``. Seeded through the
|
|
admin routes so the rows are exactly what the admin plane writes.
|
|
"""
|
|
child = await client.post(
|
|
"/api/v1/admin/children",
|
|
json={"display_name": "Ada"},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert child.status_code == 201
|
|
child_id = child.json()["id"]
|
|
|
|
active = await client.post(
|
|
LIST_PATH,
|
|
json={
|
|
"title": "Tidy the den",
|
|
"rule": {
|
|
"rule_type": "weekly",
|
|
"interval": 1,
|
|
"weekday_set": [0, 2],
|
|
"start_date": "2026-03-02",
|
|
},
|
|
"assignee_child_ids": [child_id],
|
|
"windows": ["morning", ["evening", "19:30"]],
|
|
"description": "Put everything back where it belongs.",
|
|
"icon": "toy-box",
|
|
},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert active.status_code == 201
|
|
|
|
inactive = await client.post(
|
|
LIST_PATH,
|
|
json={
|
|
"title": "Water the plants",
|
|
"rule": {
|
|
"rule_type": "daily",
|
|
"interval": 1,
|
|
"start_date": "2026-03-01",
|
|
},
|
|
"assignee_child_ids": [child_id],
|
|
"windows": ["afternoon"],
|
|
},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert inactive.status_code == 201
|
|
inactive_id = inactive.json()["id"]
|
|
deactivated = await client.patch(
|
|
f"{LIST_PATH}/{inactive_id}/active",
|
|
json={"is_active": False},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert deactivated.status_code == 200
|
|
|
|
return child_id, active.json()["id"], inactive_id
|
|
|
|
|
|
async def test_admin_lists_active_and_inactive_definitions_with_full_shape(
|
|
admin_client: object,
|
|
) -> None:
|
|
"""Both definitions come back, by rising id, in the documented shape."""
|
|
child_id, active_id, inactive_id = await _seed_active_and_inactive(
|
|
admin_client
|
|
)
|
|
|
|
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"definitions": [
|
|
{
|
|
"id": active_id,
|
|
"title": "Tidy the den",
|
|
"description": "Put everything back where it belongs.",
|
|
"icon": "lucide:toy-box",
|
|
"is_active": True,
|
|
"skip_on_away": True,
|
|
"rule": {
|
|
"rule_type": "weekly",
|
|
"interval": 1,
|
|
"weekday_set": [0, 2],
|
|
"day_of_month": None,
|
|
"nth_weekday": None,
|
|
"nth_weekday_weekday": None,
|
|
"month": None,
|
|
"start_date": "2026-03-02",
|
|
"end_date": None,
|
|
},
|
|
"assignees": [{"id": child_id, "display_name": "Ada"}],
|
|
"windows": [
|
|
{"window": "morning", "due_time": None},
|
|
{"window": "evening", "due_time": "19:30"},
|
|
],
|
|
},
|
|
{
|
|
"id": inactive_id,
|
|
"title": "Water the plants",
|
|
"description": None,
|
|
"icon": None,
|
|
"is_active": False,
|
|
"skip_on_away": True,
|
|
"rule": {
|
|
"rule_type": "daily",
|
|
"interval": 1,
|
|
"weekday_set": None,
|
|
"day_of_month": None,
|
|
"nth_weekday": None,
|
|
"nth_weekday_weekday": None,
|
|
"month": None,
|
|
"start_date": "2026-03-01",
|
|
"end_date": None,
|
|
},
|
|
"assignees": [{"id": child_id, "display_name": "Ada"}],
|
|
"windows": [{"window": "afternoon", "due_time": None}],
|
|
},
|
|
]
|
|
}
|
|
|
|
|
|
async def test_list_items_match_the_create_response_shape(
|
|
admin_client: object,
|
|
) -> None:
|
|
"""Each listed item is exactly what the create route returned."""
|
|
child = await admin_client.post(
|
|
"/api/v1/admin/children",
|
|
json={"display_name": "Ben"},
|
|
headers=_admin_headers(),
|
|
)
|
|
created = await admin_client.post(
|
|
LIST_PATH,
|
|
json={
|
|
"title": "Feed the cat",
|
|
"rule": {
|
|
"rule_type": "daily",
|
|
"interval": 1,
|
|
"start_date": "2026-03-01",
|
|
},
|
|
"assignee_child_ids": [child.json()["id"]],
|
|
"windows": ["morning"],
|
|
},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert created.status_code == 201
|
|
|
|
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"definitions": [created.json()]}
|
|
|
|
|
|
async def test_skip_on_away_round_trips_true_and_false_through_list(
|
|
admin_client: object,
|
|
) -> None:
|
|
"""skip_on_away posted true, false, or omitted lists back as stored."""
|
|
child = await admin_client.post(
|
|
"/api/v1/admin/children",
|
|
json={"display_name": "Cleo"},
|
|
headers=_admin_headers(),
|
|
)
|
|
child_id = child.json()["id"]
|
|
|
|
async def create(title: str, extra: dict[str, object]) -> dict:
|
|
response = await admin_client.post(
|
|
LIST_PATH,
|
|
json={
|
|
"title": title,
|
|
"rule": {
|
|
"rule_type": "daily",
|
|
"interval": 1,
|
|
"start_date": "2026-03-01",
|
|
},
|
|
"assignee_child_ids": [child_id],
|
|
"windows": ["morning"],
|
|
**extra,
|
|
},
|
|
headers=_admin_headers(),
|
|
)
|
|
assert response.status_code == 201
|
|
return response.json()
|
|
|
|
skipping = await create("Skip when away", {"skip_on_away": True})
|
|
keeping = await create("Keep when away", {"skip_on_away": False})
|
|
defaulted = await create("Default", {})
|
|
assert skipping["skip_on_away"] is True
|
|
assert keeping["skip_on_away"] is False
|
|
assert defaulted["skip_on_away"] is True
|
|
|
|
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
|
|
|
assert response.status_code == 200
|
|
listed = {item["id"]: item for item in response.json()["definitions"]}
|
|
assert listed[skipping["id"]]["skip_on_away"] is True
|
|
assert listed[keeping["id"]]["skip_on_away"] is False
|
|
assert listed[defaulted["id"]]["skip_on_away"] is True
|
|
assert response.json() == {"definitions": [skipping, keeping, defaulted]}
|
|
|
|
|
|
async def test_empty_household_lists_no_definitions(
|
|
admin_client: object,
|
|
) -> None:
|
|
"""With no definitions stored the list is 200 and empty."""
|
|
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"definitions": []}
|
|
|
|
|
|
async def test_list_without_credentials_is_401(admin_client: object) -> None:
|
|
"""An absent credential is the uniform 401."""
|
|
response = await admin_client.get(LIST_PATH)
|
|
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_list_with_panel_service_token_is_403(
|
|
admin_client: object,
|
|
) -> None:
|
|
"""The panel service token is refused outright, never a JWT."""
|
|
response = await admin_client.get(
|
|
LIST_PATH,
|
|
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
|
|
|
|
|
async def test_list_with_non_admin_jwt_is_403(admin_client: object) -> None:
|
|
"""A valid JWT without the nestquest-admins group is 403."""
|
|
non_admin = make_token(groups=("some-other-group",))
|
|
response = await admin_client.get(
|
|
LIST_PATH, headers={"Authorization": f"Bearer {non_admin}"}
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"] == FORBIDDEN_DETAIL
|