"""Admin user management (SPEC ยง6.1): search, quota, block, delete. Runs against the dev database with real sessions, so every user it creates is removed again on the way out. """ from __future__ import annotations import uuid from collections.abc import AsyncIterator import httpx import pytest import pytest_asyncio from httpx import ASGITransport from sqlalchemy import delete, select, text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from app.config import get_settings from app.identity import user_admin from app.identity.passwords import hash_password from app.identity.sessions import create_session, resolve_session from app.main import create_app from app.models import Account, PluginKv, User from app.plugins.db import plugin_session CSRF = "csrf-token" PASSWORD = "hunter2pass" def _handle() -> str: return f"adm{uuid.uuid4().hex[:8]}@test.local" class World: def __init__(self, session: AsyncSession) -> None: self.session = session self.created: list[uuid.UUID] = [] async def user(self, *, admin: bool = False) -> User: user = User(handle=_handle(), password_hash=hash_password(PASSWORD), is_admin=admin) self.session.add(user) await self.session.commit() self.created.append(user.id) return user async def client(self, user: User) -> httpx.AsyncClient: token = await create_session(user.id, "pytest") return httpx.AsyncClient( transport=ASGITransport(app=create_app()), base_url="http://testserver", cookies={"session": token, "csrf": CSRF}, headers={"X-CSRF": CSRF}, ) @pytest_asyncio.fixture async def world() -> AsyncIterator[World]: engine = create_async_engine(get_settings().database_url, poolclass=NullPool) session = async_sessionmaker(engine, expire_on_commit=False)() w = World(session) try: yield w finally: await session.rollback() await session.execute(delete(User).where(User.id.in_(w.created))) await session.commit() await session.close() await engine.dispose() def _account(user_id: uuid.UUID, email: str, name: str = "") -> Account: return Account( user_id=user_id, email=email, display_name=name, imap_config={}, smtp_config={}, secret_ct=b"x", discovery_src="manual", state="live", ) async def test_only_an_admin_can_manage_users(world: World) -> None: plain = await world.user() async with await world.client(plain) as c: assert (await c.get("/api/admin/users")).status_code == 403 async def test_search_by_login_and_by_mail_account(world: World) -> None: admin = await world.user(admin=True) target = await world.user() tag = uuid.uuid4().hex[:10] world.session.add(_account(target.id, f"x{tag}@example.com", f"Zed {tag}")) await world.session.commit() async with await world.client(admin) as c: by_login = (await c.get("/api/admin/users", params={"q": target.handle[:11]})).json() assert [u["id"] for u in by_login] == [str(target.id)] by_email = (await c.get("/api/admin/users", params={"q": f"x{tag}@"})).json() assert [u["id"] for u in by_email] == [str(target.id)] assert by_email[0]["accounts"] == [f"x{tag}@example.com"] by_name = (await c.get("/api/admin/users", params={"q": f"zed {tag}"})).json() assert [u["id"] for u in by_name] == [str(target.id)] # A typed % is a character, not "match anything". assert (await c.get("/api/admin/users", params={"q": f"{tag}%"})).json() == [] async def test_change_quota(world: World) -> None: admin = await world.user(admin=True) target = await world.user() async with await world.client(admin) as c: resp = await c.patch(f"/api/admin/users/{target.id}", json={"quota_bytes": 123456789}) assert resp.status_code == 200 assert resp.json()["quota_bytes"] == 123456789 bad = await c.patch(f"/api/admin/users/{target.id}", json={"quota_bytes": 0}) assert bad.status_code == 422 class _Bus: def __init__(self) -> None: self.published: list[tuple[str, dict[str, object]]] = [] async def publish(self, event: str, payload: dict[str, object]) -> None: self.published.append((event, payload)) @pytest.fixture def bus(monkeypatch: pytest.MonkeyPatch) -> _Bus: fake = _Bus() monkeypatch.setattr(user_admin, "get_event_bus", lambda: fake) return fake async def test_block_refuses_login_and_ends_sessions_then_unblock(world: World, bus: _Bus) -> None: admin = await world.user(admin=True) target = await world.user() token = await create_session(target.id, "pytest") async with await world.client(admin) as c: resp = await c.patch(f"/api/admin/users/{target.id}", json={"blocked": True}) assert resp.status_code == 200 and resp.json()["blocked_at"] # The session the user held is gone, and their open tabs are told to leave. assert await resolve_session(token) is None assert ("user.signed_out", {"user_id": str(target.id)}) in bus.published anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t") async with anon: # ...and they cannot sign back in, told why only after a correct password. login = {"handle": target.handle, "password": PASSWORD} blocked = await anon.post("/api/auth/login", json=login) assert blocked.status_code == 403 assert blocked.json()["error"]["code"] == "account_blocked" wrong = await anon.post("/api/auth/login", json={**login, "password": "nope-nope"}) assert wrong.json()["error"]["code"] == "invalid_credentials" async with await world.client(admin) as c: resp = await c.patch(f"/api/admin/users/{target.id}", json={"blocked": False}) assert resp.json()["blocked_at"] is None assert (await anon.post("/api/auth/login", json=login)).status_code == 200 async def test_admin_cannot_block_or_delete_themselves(world: World) -> None: admin = await world.user(admin=True) async with await world.client(admin) as c: assert ( await c.patch(f"/api/admin/users/{admin.id}", json={"blocked": True}) ).status_code == 409 assert (await c.delete(f"/api/admin/users/{admin.id}")).status_code == 409 async def test_admin_can_change_their_own_quota(world: World) -> None: admin = await world.user(admin=True) async with await world.client(admin) as c: resp = await c.patch(f"/api/admin/users/{admin.id}", json={"quota_bytes": 5 * 2**30}) assert resp.status_code == 200 assert resp.json()["quota_bytes"] == 5 * 2**30 # Quota together with a block is still refused: the block is the problem. assert ( await c.patch( f"/api/admin/users/{admin.id}", json={"quota_bytes": 2**30, "blocked": True} ) ).status_code == 409 async def test_delete_removes_the_user_and_their_data(world: World, bus: _Bus) -> None: admin = await world.user(admin=True) target = await world.user() world.session.add(_account(target.id, f"d{uuid.uuid4().hex[:8]}@example.com")) world.session.add(PluginKv(plugin="labels", user_id=target.id, key="k", value={"a": 1})) await world.session.commit() # A plugin row: no foreign key to users, behind row-level security. async with plugin_session(target.id) as ps: await ps.execute( text("INSERT INTO plg_contacts_books (user_id, name) VALUES (:u, 'Book')"), {"u": target.id}, ) token = await create_session(target.id, "pytest") async with await world.client(admin) as c: assert (await c.delete(f"/api/admin/users/{target.id}")).status_code == 200 s = world.session assert await s.scalar(select(User).where(User.id == target.id)) is None assert await s.scalar(select(Account).where(Account.user_id == target.id)) is None assert await s.scalar(select(PluginKv).where(PluginKv.user_id == target.id)) is None async with plugin_session(target.id) as ps: left = await ps.scalar(text("SELECT count(*) FROM plg_contacts_books")) assert left == 0 assert await resolve_session(token) is None assert ("user.signed_out", {"user_id": str(target.id)}) in bus.published