Files
curo1305 44ec28d474 test(08-03): promote CR-01/CR-02/CR-03 session-revocation tests from xfail to passing
- Remove @pytest.mark.xfail decorator from test_change_password_revokes_other_sessions (CR-01)
- Remove @pytest.mark.xfail decorator from test_enable_totp_revokes_other_sessions (CR-02)
- Remove @pytest.mark.xfail decorator from test_disable_totp_revokes_other_sessions (CR-03)
- All three tests now show PASSED (not XPASS) in strict pytest runs
2026-06-08 16:35:26 +02:00

347 lines
14 KiB
Python

"""
Wave 0 xfail stubs for CR-01, CR-02, CR-03 — session revocation on privilege change.
These tests lock the behavioral contract expected by the session-revocation
implementation already present in backend/api/auth.py. They are marked xfail
with strict=False so that XPASS (already passing) or XFAIL both succeed during
Wave 0. Plan 08-03 removes the @pytest.mark.xfail decorator and promotes them to
strict passing tests.
CR-01: change_password revokes all other refresh tokens; current session preserved
CR-02: enable_totp revokes all other refresh tokens; current session preserved
CR-03: disable_totp revokes all other refresh tokens; current session preserved
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import patch
import pyotp
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
# ── Helpers ───────────────────────────────────────────────────────────────────
VALID_PASSWORD = "StrongPass12!"
class FakeRedis:
"""In-memory fake Redis for testing. Mirrors test_auth_api.py's FakeRedis."""
def __init__(self):
self._store: dict = {}
async def get(self, key):
entry = self._store.get(key)
if entry is None:
return None
val, exp = entry
if exp is not None and datetime.now(timezone.utc).timestamp() > exp:
del self._store[key]
return None
return val
async def incr(self, key):
entry = self._store.get(key)
if entry is None:
self._store[key] = (1, None)
return 1
val, exp = entry
new_val = val + 1
self._store[key] = (new_val, exp)
return new_val
async def expire(self, key, seconds):
if key in self._store:
val, _ = self._store[key]
deadline = datetime.now(timezone.utc).timestamp() + seconds
self._store[key] = (val, deadline)
async def set(self, key, value, ex=None):
deadline = None
if ex is not None:
deadline = datetime.now(timezone.utc).timestamp() + ex
self._store[key] = (value, deadline)
async def close(self):
pass
@pytest_asyncio.fixture
async def revoke_client(db_session: AsyncSession):
"""Async HTTP test client with DB override and fresh FakeRedis.
All session-revocation tests use this fixture so they share a clean
in-memory state for both the DB and Redis (per-account rate limiter).
The client sends a fixed User-Agent ("docuvault-test/1.0") matching
the conftest._TEST_USER_AGENT constant. This is required because the
access token's `fgp` claim is bound to the User-Agent at login time —
subsequent requests using that access token must send the same User-Agent
or `get_current_user` returns 401 "Token fingerprint mismatch".
"""
from deps.db import get_db
from main import app
from api.auth import limiter as auth_limiter
app.dependency_overrides[get_db] = lambda: db_session
fake_redis = FakeRedis()
app.state.redis = fake_redis
try:
auth_limiter._storage.reset()
except Exception:
pass
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"User-Agent": "docuvault-test/1.0"},
) as c:
yield c
app.dependency_overrides.clear()
app.state.redis = None
async def _register_user(client: AsyncClient, handle: str, email: str) -> None:
"""Register a user account; assert 201."""
resp = await client.post(
"/api/auth/register",
json={"handle": handle, "email": email, "password": VALID_PASSWORD},
)
assert resp.status_code == 201, f"Register failed: {resp.text}"
async def _login_session(
client: AsyncClient, email: str, user_agent: str | None = None
) -> tuple[str, str]:
"""Login and return (access_token, refresh_token_cookie_value).
Each call to login creates a new RefreshToken row in the DB, so two
sequential calls yield two separate sessions even with the same User-Agent.
The user_agent parameter controls the fgp claim embedded in the access token —
if supplied, the same User-Agent must be used for all subsequent requests
that present that access token.
"""
headers: dict = {}
if user_agent:
headers["User-Agent"] = user_agent
resp = await client.post(
"/api/auth/login",
json={"email": email, "password": VALID_PASSWORD},
headers=headers,
)
assert resp.status_code == 200, f"Login failed: {resp.text}"
data = resp.json()
access_token = data["access_token"]
# The refresh cookie is set with path=/api/auth/refresh — extract the raw value
# so we can inject it explicitly when needed.
refresh_cookie = resp.cookies.get("refresh_token") or ""
return access_token, refresh_cookie
async def _try_refresh(
client: AsyncClient, refresh_token: str
) -> int:
"""POST /api/auth/refresh with the given refresh_token cookie; return status code.
Patches the security alert email task so presenting a revoked token does not
attempt a real Celery/Redis broker connection (which is unavailable in unit tests).
"""
from unittest.mock import MagicMock
mock_task = MagicMock()
mock_task.delay = MagicMock()
with patch.dict(
"sys.modules",
{"tasks.email_tasks": MagicMock(send_security_alert_email=mock_task)},
):
resp = await client.post(
"/api/auth/refresh",
cookies={"refresh_token": refresh_token},
)
return resp.status_code
# ── Tests ─────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_change_password_revokes_other_sessions(revoke_client, db_session):
"""CR-01: change_password revokes other sessions; current session preserved.
Setup: register user; login twice (sessions A and B).
Action: using session A's access token, POST /api/auth/change-password with
session A's refresh_token cookie so skip_token_hash preserves session A.
Assert:
- response 200 with sessions_revoked == 1
- session A refresh token still valid (200 on /api/auth/refresh)
- session B refresh token revoked (401 on /api/auth/refresh)
"""
email = "cr01_test@example.com"
await _register_user(revoke_client, handle="cr01user", email=email)
# Two sequential logins create two separate RefreshToken rows (sessions A and B).
# The client uses a fixed User-Agent so the access token fgp claim matches
# subsequent requests that present token_a.
token_a, refresh_a = await _login_session(revoke_client, email)
_token_b, refresh_b = await _login_session(revoke_client, email)
with patch("services.auth.check_hibp", return_value=False):
resp = await revoke_client.post(
"/api/auth/change-password",
json={"current_password": VALID_PASSWORD, "new_password": "NewValidPass99!"},
headers={"Authorization": f"Bearer {token_a}"},
cookies={"refresh_token": refresh_a},
)
assert resp.status_code == 200, f"change_password failed: {resp.text}"
data = resp.json()
assert data["sessions_revoked"] == 1, (
f"Expected exactly 1 other session revoked, got {data['sessions_revoked']}"
)
# Session A's refresh token must still be usable
status_a = await _try_refresh(revoke_client, refresh_a)
assert status_a == 200, f"Session A refresh should still be valid, got {status_a}"
# Session B's refresh token must now be revoked
status_b = await _try_refresh(revoke_client, refresh_b)
assert status_b == 401, f"Session B refresh should be revoked (401), got {status_b}"
@pytest.mark.asyncio
async def test_enable_totp_revokes_other_sessions(revoke_client, db_session):
"""CR-02: enable_totp revokes other sessions; current session preserved.
Setup: register user; login twice (sessions A and B).
Action: using session A, GET /api/auth/totp/setup to obtain secret,
derive a valid TOTP code via pyotp.TOTP(secret).now(),
POST /api/auth/totp/enable with that code and session A's refresh cookie.
Assert:
- response 200 with sessions_revoked == 1
- session A refresh token still valid (200 on /api/auth/refresh)
- session B refresh token revoked (401 on /api/auth/refresh)
"""
email = "cr02_test@example.com"
await _register_user(revoke_client, handle="cr02user", email=email)
# Two sequential logins create two separate RefreshToken rows (sessions A and B).
token_a, refresh_a = await _login_session(revoke_client, email)
_token_b, refresh_b = await _login_session(revoke_client, email)
# Obtain TOTP secret for this user
setup_resp = await revoke_client.get(
"/api/auth/totp/setup",
headers={"Authorization": f"Bearer {token_a}"},
)
assert setup_resp.status_code == 200, f"TOTP setup failed: {setup_resp.text}"
secret = setup_resp.json()["secret"]
# Generate a valid TOTP code from the provisioned secret
totp_code = pyotp.TOTP(secret).now()
resp = await revoke_client.post(
"/api/auth/totp/enable",
json={"code": totp_code},
headers={"Authorization": f"Bearer {token_a}"},
cookies={"refresh_token": refresh_a},
)
assert resp.status_code == 200, f"enable_totp failed: {resp.text}"
data = resp.json()
assert data["sessions_revoked"] == 1, (
f"Expected exactly 1 other session revoked, got {data['sessions_revoked']}"
)
# Session A's refresh token must still be usable
status_a = await _try_refresh(revoke_client, refresh_a)
assert status_a == 200, f"Session A refresh should still be valid, got {status_a}"
# Session B's refresh token must now be revoked
status_b = await _try_refresh(revoke_client, refresh_b)
assert status_b == 401, f"Session B refresh should be revoked (401), got {status_b}"
@pytest.mark.asyncio
async def test_disable_totp_revokes_other_sessions(revoke_client, db_session):
"""CR-03: disable_totp revokes other sessions; current session preserved.
Setup: register user, enable TOTP directly in DB to avoid a setup session,
then log in twice with TOTP codes (sessions A and B).
Action: using session A, DELETE /api/auth/totp with session A's refresh cookie.
Assert:
- response 200 with sessions_revoked == 1
- session A refresh token still valid (200 on /api/auth/refresh)
- session B refresh token revoked (401 on /api/auth/refresh)
"""
from sqlalchemy import select
from db.models import User
email = "cr03_test@example.com"
await _register_user(revoke_client, handle="cr03user", email=email)
# Retrieve the user and enable TOTP directly in the DB so there is no
# "setup session" refresh token that would skew the sessions_revoked count.
# Using a known fixed secret makes TOTP code generation deterministic.
known_secret = "JBSWY3DPEHPK3PXP"
result = await db_session.execute(select(User).where(User.email == email))
user = result.scalar_one()
user.totp_enabled = True
user.totp_secret = known_secret
await db_session.commit()
# Login twice with TOTP, patching verify_totp to bypass the Redis replay
# prevention that would block reuse of the same code within 90 seconds.
# We test session revocation here, not TOTP code validation.
with patch("services.auth.verify_totp", return_value=True):
# Step 1: submit password — server responds with requires_totp (no TOTP provided)
step1_a = await revoke_client.post(
"/api/auth/login",
json={"email": email, "password": VALID_PASSWORD},
)
assert step1_a.status_code == 200, f"Session A login step 1 failed: {step1_a.text}"
assert step1_a.json().get("requires_totp"), f"Expected requires_totp: {step1_a.json()}"
# Step 2A: complete session A login with a TOTP code
step2_a = await revoke_client.post(
"/api/auth/login",
json={"email": email, "password": VALID_PASSWORD, "totp_code": "000000"},
)
assert step2_a.status_code == 200, f"Session A login step 2 failed: {step2_a.text}"
token_a = step2_a.json()["access_token"]
refresh_a = step2_a.cookies.get("refresh_token") or ""
# Step 2B: complete session B login with a TOTP code (same code OK — verify_totp patched)
step2_b = await revoke_client.post(
"/api/auth/login",
json={"email": email, "password": VALID_PASSWORD, "totp_code": "000000"},
)
assert step2_b.status_code == 200, f"Session B login step 2 failed: {step2_b.text}"
_token_b = step2_b.json()["access_token"]
refresh_b = step2_b.cookies.get("refresh_token") or ""
# Disable TOTP using session A — DELETE /api/auth/totp requires no TOTP code in body
resp = await revoke_client.delete(
"/api/auth/totp",
headers={"Authorization": f"Bearer {token_a}"},
cookies={"refresh_token": refresh_a},
)
assert resp.status_code == 200, f"disable_totp failed: {resp.text}"
data = resp.json()
assert data["sessions_revoked"] == 1, (
f"Expected exactly 1 other session revoked, got {data['sessions_revoked']}"
)
# Session A's refresh token must still be usable
status_a = await _try_refresh(revoke_client, refresh_a)
assert status_a == 200, f"Session A refresh should still be valid, got {status_a}"
# Session B's refresh token must now be revoked
status_b = await _try_refresh(revoke_client, refresh_b)
assert status_b == 401, f"Session B refresh should be revoked (401), got {status_b}"