167 lines
5.8 KiB
Python
167 lines
5.8 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():
|
||
|
|
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
|
||
|
|
|
||
|
|
|
||
|
|
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", 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: calls.append((login, 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)]
|
||
|
|
|
||
|
|
|
||
|
|
@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
|