lct-hack/backend/tests/test_auth_hardening.py
2026-09-24 01:10:49 +03:00

180 lines
6.3 KiB
Python

"""Regressions for stale cookies and privileged admin operations."""
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
import app.api.auth as auth
from app.api.http import admin
from app.domain.roles import Role
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client():
# Each TestClient represents a fresh backend process. In particular,
# revocations exercised by a previous test must not leak into the next
# process' in-memory cache.
auth.prime_generations({})
with TestClient(app) as test_client:
hub.journal = None
yield test_client
def test_account_change_revokes_http_and_new_websocket_handshakes(client):
assert client.post("/api/auth/dev-token").status_code == 200
assert client.get("/api/auth/me").status_code == 200
auth.invalidate_login("dev")
assert client.get("/api/auth/me").status_code == 401
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
assert socket.receive_json()["code"] == "forbidden"
assert client.post("/api/auth/dev-token").status_code == 200
assert client.get("/api/auth/me").status_code == 200
def test_account_change_closes_an_existing_websocket(client):
assert client.post("/api/auth/dev-token").status_code == 200
with client.websocket_connect(f"/ws/control/{uuid4()}") as socket:
auth.invalidate_login("dev")
with pytest.raises(WebSocketDisconnect) as exc:
socket.receive_json()
assert exc.value.code == 1008
def test_cookie_from_previous_process_is_rejected(client, monkeypatch):
assert client.post("/api/auth/dev-token").status_code == 200
monkeypatch.setattr(auth, "_INSTANCE", "new-server-instance")
assert client.get("/api/auth/me").status_code == 401
def test_cookie_survives_generation_cache_reload_when_account_is_unchanged(client):
assert client.post("/api/auth/dev-token").status_code == 200
auth.prime_generations({"dev": 0})
assert client.get("/api/auth/me").status_code == 200
class FakeDb:
def __init__(self, user):
self.user = user
self.commits = 0
self.added = []
async def get(self, _model, _id):
return self.user
def add(self, object_):
self.added.append(object_)
async def flush(self):
for object_ in self.added:
if getattr(object_, "id", None) is None:
object_.id = uuid4()
async def commit(self):
self.commits += 1
def fake_user(login="victim", role="instructor"):
return SimpleNamespace(
id=uuid4(), login=login, full_name="Проверка", role=role,
service=None, trainee_id=None, blocked=False,
password_hash="old", auth_version=0, created_at=datetime.now(timezone.utc),
)
@pytest.mark.asyncio
async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
user = fake_user()
db = FakeDb(user)
calls = []
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
monkeypatch.setattr(
admin, "invalidate_login",
lambda login, version=None: calls.append((login, version, db.commits)),
)
async def no_audit(*_args, **_kwargs):
return None
monkeypatch.setattr(admin, "audit", no_audit)
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
assert user.blocked is True
assert calls == [("victim", 1, 1)]
@pytest.mark.asyncio
async def test_admin_cannot_demote_self(monkeypatch):
user = fake_user(login="admin", role="admin")
db = FakeDb(user)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
with pytest.raises(HTTPException) as exc:
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
assert exc.value.status_code == 409
assert db.commits == 0
@pytest.mark.asyncio
async def test_promotion_to_trainee_creates_profile(monkeypatch):
user = fake_user()
db = FakeDb(user)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
async def no_audit(*_args, **_kwargs):
return None
monkeypatch.setattr(admin, "audit", no_audit)
await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db)
assert user.role == "trainee"
assert user.trainee_id is not None
assert db.commits == 1
@pytest.mark.asyncio
async def test_backup_runs_off_event_loop_and_failure_is_audited(monkeypatch):
who = auth.Principal(login="admin", full_name="Администратор", role=Role.ADMIN)
monkeypatch.setattr(admin, "require", lambda _request, *_roles: who)
calls = []
async def fake_threadpool(fn):
calls.append(fn)
return fn()
async def fake_audit(*args, **kwargs):
calls.append((args, kwargs))
monkeypatch.setattr(admin, "run_in_threadpool", fake_threadpool)
monkeypatch.setattr(admin, "audit", fake_audit)
monkeypatch.setattr(admin.backup_service, "create", lambda: {
"name": "example.sql", "size_bytes": 1, "at": datetime.now(timezone.utc),
})
assert (await admin.make_backup(object())).name == "example.sql"
assert calls[0] is admin.backup_service.create
def broken():
raise admin.backup_service.BackupError("pg_dump failed")
monkeypatch.setattr(admin.backup_service, "create", broken)
with pytest.raises(HTTPException) as exc:
await admin.make_backup(object())
assert exc.value.status_code == 503
assert any(isinstance(item, tuple) and item[0][2] == "backup.failed" for item in calls)
def test_backup_error_redacts_database_credentials(monkeypatch):
dsn = "postgresql://user:supersecret@localhost:5432/example"
monkeypatch.setattr(admin, "get_settings", lambda: SimpleNamespace(database_url=dsn))
detail = admin._safe_backup_error(admin.backup_service.BackupError(f"bad DSN: {dsn}"))
assert "supersecret" not in detail
assert dsn not in detail