diff --git a/backend/api/admin.py b/backend/api/admin.py index 98bd5e2..ee26e7e 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -23,10 +23,12 @@ Security invariants: """ from __future__ import annotations +import time import uuid from datetime import datetime from typing import Optional +from config import settings from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from sqlalchemy import func, select, update @@ -351,6 +353,12 @@ async def update_user_status( if not body.is_active: # Revoke all refresh tokens on deactivation await revoke_all_refresh_tokens(session, user.id) + # Revoke any pre-deactivation access tokens still within their TTL (T-7.2-01) + await request.app.state.redis.set( + f"user_nbf:{user.id}", + int(time.time()), + ex=settings.access_token_expire_minutes * 60, + ) session.add(user) diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py index 6bb819d..5e16d8a 100644 --- a/backend/tests/test_admin_api.py +++ b/backend/tests/test_admin_api.py @@ -450,10 +450,11 @@ async def test_delete_user_no_body(admin_client): # 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.""" + from main import app + client, _admin, session = admin_client target = await make_regular_user(session) @@ -463,11 +464,11 @@ async def test_deactivate_user_writes_user_nbf_to_redis(admin_client): ) 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" + nbf_bytes = await app.state.redis.get(f"user_nbf:{target.id}") + assert nbf_bytes is not None + assert int(nbf_bytes.decode() if isinstance(nbf_bytes, (bytes, bytearray)) else nbf_bytes) > 0 -@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. @@ -498,5 +499,5 @@ async def test_activate_user_does_not_write_user_nbf(admin_client): ) 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" + nbf_bytes = await app.state.redis.get(f"user_nbf:{target.id}") + assert nbf_bytes is None, "activation must NOT write user_nbf (anti-pattern guard)"