Files
webmail-client/backend/tests/api/test_admin_users.py
T

197 lines
7.7 KiB
Python
Raw Normal View History

"""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_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