"""Admin panel (/admin) integration tests: auth gate, create, edit, ban. Run against the Docker Compose infrastructure (`docker compose up -d`). Covers the server-rendered FastAPI + Jinja2 + HTMX panel mounted in the api. """ from __future__ import annotations import uuid import httpx import pytest from sqlalchemy import text from contract_check.core.security.passwords import hash_password pytestmark = pytest.mark.integration async def _seed_user( db_session, *, email: str, password: str, role: str = "user", is_active: bool = True, credits_left: int = 0, ) -> str: """Insert a user and return its id (cleaned up by the per-test session rollback semantics; we also delete explicitly to keep tables tidy across tests).""" result = await db_session.execute( text( "INSERT INTO users (email, password_hash, role, is_active, credits_left) " "VALUES (:e, :p, :r, :a, :c) RETURNING id" ), { "e": email, "p": hash_password(password), "r": role, "a": is_active, "c": credits_left, }, ) await db_session.commit() user_id = result.first()[0] return str(user_id) async def _login_as(client: httpx.AsyncClient, email: str, password: str) -> httpx.Response: return await client.post( "/admin/login", data={"email": email, "password": password}, follow_redirects=False ) async def test_admin_login_sets_cookie_for_admin(client: httpx.AsyncClient, db_session) -> None: email = f"admin-{uuid.uuid4().hex[:8]}@test.local" await _seed_user(db_session, email=email, password="adminpass-123", role="admin") r = await _login_as(client, email, "adminpass-123") assert r.status_code == 303 assert r.headers["location"] == "/admin/users" assert "cc_admin_token" in r.cookies async def test_admin_login_rejects_non_admin_role(client: httpx.AsyncClient, db_session) -> None: email = f"user-{uuid.uuid4().hex[:8]}@test.local" await _seed_user(db_session, email=email, password="userpass-123", role="user") r = await _login_as(client, email, "userpass-123") assert r.status_code == 303 assert "forbidden" in r.headers["location"] assert "cc_admin_token" not in r.cookies async def test_admin_routes_redirect_without_session(client: httpx.AsyncClient) -> None: r = await client.get("/admin/users", follow_redirects=False) assert r.status_code == 303 assert r.headers["location"].startswith("/admin/login") async def test_admin_creates_user_and_redirects_to_detail( client: httpx.AsyncClient, db_session ) -> None: admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local" await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin") await _login_as(client, admin_email, "adminpass-123") new_email = f"new-{uuid.uuid4().hex[:8]}@test.local" r = await client.post( "/admin/users", data={ "email": new_email, "password": "newpass-1234", "role": "admin", "credits_left": "7", "is_active": "on", }, follow_redirects=False, ) assert r.status_code == 303 location = r.headers["location"] assert location.startswith("/admin/users/") # Persisted with the chosen role + credits. row = ( await db_session.execute( text( "SELECT credits_left, role, is_active, password_hash IS NOT NULL " "FROM users WHERE email = :e" ), {"e": new_email}, ) ).first() assert row is not None assert row[0] == 7 assert row[1] == "admin" assert row[2] is True assert row[3] is True # Detail page renders the created user. detail = await client.get(location, follow_redirects=False) assert detail.status_code == 200 assert new_email in detail.text async def test_admin_create_rejects_duplicate_email(client: httpx.AsyncClient, db_session) -> None: admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local" existing = f"dup-{uuid.uuid4().hex[:8]}@test.local" await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin") await _seed_user(db_session, email=existing, password="somepass-123") await _login_as(client, admin_email, "adminpass-123") r = await client.post( "/admin/users", data={"email": existing, "password": "anotherpass-123"}, follow_redirects=False, ) assert r.status_code == 200 # form re-rendered, not a redirect/5xx assert "уже существует" in r.text async def test_admin_create_rejects_short_password(client: httpx.AsyncClient, db_session) -> None: admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local" await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin") await _login_as(client, admin_email, "adminpass-123") r = await client.post( "/admin/users", data={"email": f"short-{uuid.uuid4().hex[:8]}@test.local", "password": "x"}, follow_redirects=False, ) assert r.status_code == 200 assert "Пароль короче" in r.text async def test_admin_new_form_resolves_before_dynamic_route( client: httpx.AsyncClient, db_session ) -> None: """`/admin/users/new` must hit the form route, not be parsed as {user_id}.""" admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local" await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin") await _login_as(client, admin_email, "adminpass-123") r = await client.get("/admin/users/new", follow_redirects=False) assert r.status_code == 200 assert "Новый пользователь" in r.text async def test_admin_toggle_active_bans_user(client: httpx.AsyncClient, db_session) -> None: admin_email = f"admin-{uuid.uuid4().hex[:8]}@test.local" await _seed_user(db_session, email=admin_email, password="adminpass-123", role="admin") await _login_as(client, admin_email, "adminpass-123") target_email = f"ban-{uuid.uuid4().hex[:8]}@test.local" target_id = await _seed_user(db_session, email=target_email, password="targetpass-12") r = await client.post( f"/admin/users/{target_id}/toggle-active", headers={"hx-request": "true"}, follow_redirects=False, ) assert r.status_code == 200 is_active = ( await db_session.execute( text("SELECT is_active FROM users WHERE id = :u"), {"u": target_id} ) ).scalar_one() assert is_active is False