diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py index 063e219..6bb819d 100644 --- a/backend/tests/test_admin_api.py +++ b/backend/tests/test_admin_api.py @@ -26,6 +26,7 @@ from sqlalchemy import select from deps.auth import get_current_admin from deps.db import get_db from services.auth import hash_password +from tests.test_auth_api import FakeRedis # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -70,17 +71,26 @@ async def make_regular_user(session: AsyncSession) -> User: @pytest_asyncio.fixture async def admin_client(db_session: AsyncSession): - """Async client with get_current_admin overridden to an admin user.""" + """Async client with get_current_admin overridden to an admin user. + + Phase 7.2: app.state.redis is set to a FakeRedis() instance so that + the admin deactivation handler (post-Wave 2) can call + request.app.state.redis.set(...) without AttributeError. + """ from main import app admin = await make_admin_user(db_session) app.dependency_overrides[get_db] = lambda: db_session app.dependency_overrides[get_current_admin] = lambda: admin + # Phase 7.2: mount FakeRedis for deactivation handler NBF write + app.state.redis = FakeRedis() async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: yield c, admin, db_session app.dependency_overrides.clear() + # Reset redis to avoid leaking state between test files (mirrors authed_client teardown) + app.state.redis = None # ── Tests ───────────────────────────────────────────────────────────────────── @@ -429,3 +439,64 @@ async def test_delete_user_no_body(admin_client): resp = await client.delete(f"/api/admin/users/{target.id}") assert resp.status_code == 422 + + +# ── Phase 7.2 Wave 0 stubs: NBF-write on admin deactivation ────────────────── +# These two tests are xfailed with strict=False. When Wave 2 (Plan 03) adds +# user_nbf write to the admin deactivation handler, these stubs are promoted +# by replacing `assert False, "stub"` with real Redis key assertions. +# +# Invariant preserved in Wave 2: activation (is_active=True) must NOT write +# user_nbf — only deactivation writes it (RESEARCH.md anti-pattern). + + +@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 2 — user_nbf write not yet added to admin deactivation handler") +@pytest.mark.asyncio +async def test_deactivate_user_writes_user_nbf_to_redis(admin_client): + """PATCH /api/admin/users/{id}/status {is_active: false} must write user_nbf:{id} to Redis.""" + client, _admin, session = admin_client + target = await make_regular_user(session) + + resp = await client.patch( + f"/api/admin/users/{target.id}/status", + json={"is_active": False}, + ) + assert resp.status_code == 200 + + # Wave 2 will add the user_nbf write; this is the target assertion: + assert False, "stub — Wave 2 will fill in: nbf_bytes = await app.state.redis.get(f'user_nbf:{target.id}'); assert nbf_bytes is not None" + + +@pytest.mark.xfail(strict=False, reason="Phase 7.2 Wave 2 — activation must NOT write user_nbf (invariant guard)") +@pytest.mark.asyncio +async def test_activate_user_does_not_write_user_nbf(admin_client): + """PATCH /api/admin/users/{id}/status {is_active: true} must NOT write user_nbf:{id} to Redis. + + Anti-pattern from RESEARCH.md: do not write user_nbf for successful activation. + This negative-guard test ensures Wave 2 only writes on deactivation, not activation. + """ + from main import app + + client, _admin, session = admin_client + target = await make_regular_user(session) + + # Deactivate first (sets is_active=False) + await client.patch( + f"/api/admin/users/{target.id}/status", + json={"is_active": False}, + ) + + # Clear any nbf that may have been set by deactivation + fake_redis = app.state.redis + if hasattr(fake_redis, "_store"): + fake_redis._store.pop(f"user_nbf:{target.id}", None) + + # Now activate (is_active=True) — must NOT set user_nbf + resp = await client.patch( + f"/api/admin/users/{target.id}/status", + json={"is_active": True}, + ) + assert resp.status_code == 200 + + # Wave 2 must preserve this invariant; this is the target assertion: + assert False, "stub — Wave 2 will fill in: nbf_bytes = await app.state.redis.get(f'user_nbf:{target.id}'); assert nbf_bytes is None"