Branding: the page titles and app name are now "Ultimo Email". Admin panel (now two columns with a divider): - Users: search by login or by a mail account's address or name (results only, nothing listed until something is typed). A user opens a window to change their quota, block/unblock them, or delete them; block and delete ask for confirmation, and an admin cannot do either to their own account. - Blocking (users.blocked_at, migration 0019) refuses sign-in with a clear message and revokes every session; a new user.signed_out event sends the user's open tabs to the sign-in screen at once. Any "not signed in" answer now does the same, not only /auth/me. Deleting removes mail accounts (sync stops), plugin rows (cleared inside the user's RLS context, since plugin tables have no FK to users), blobs, the search index, sessions and the user. - Outgoing system mail (migration 0020): SMTP server, port, security, login, password (vault-encrypted, never sent back), From address and the public address reset links point to, plus a "Send test" that reports the server's error. Password reset: - "Forgot password?" on the sign-in page asks for the user name and always answers "If this account exists you will receive an email to your recovery email address"; the mail goes out after the response, so neither the reply nor its timing reveals an account, and it is capped at 3 per hour. - The emailed link (token in the URL fragment, single use, 30 minutes) opens a page to set a new password. With 2-step verification on, the code is checked before anything changes; five wrong codes burn the link. - Settings > Profile: the recovery email can be added, changed or removed, confirmed with the current password, which must be typed — autofill and paste are refused (TypedPasswordInput). Also adds frontend/icons/ultimo-logo-bimi.svg, an SVG Tiny PS version of the logo for a BIMI record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
7.7 KiB
Python
197 lines
7.7 KiB
Python
"""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
|