Implement DDS exercise, customer UI and local demo
This commit is contained in:
parent
cec84ffcd0
commit
0526b11f91
46 changed files with 2504 additions and 253 deletions
167
backend/tests/test_auth_hardening.py
Normal file
167
backend/tests/test_auth_hardening.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""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
|
||||
Loading…
Reference in a new issue