Files
webmail-client/backend/tests/identity/test_identity_m1.py
T
lukasandClaude Opus 5.5 0ddc0cb001 Admin user management, password reset by email, and the Ultimo Email name
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>
2026-10-01 20:16:27 +02:00

152 lines
5.3 KiB
Python

"""M1 identity: 2FA (TOTP), session list/revoke (SPEC §6.1/§9.1). The password reset
flow is in test_password_reset.py."""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
import httpx
import pyotp
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete
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.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
CSRF = "csrf-token"
PASSWORD = "hunter2pass"
@dataclass
class Ctx:
app: object
client: httpx.AsyncClient
user_id: uuid.UUID
handle: str
session: AsyncSession
def _hdr() -> dict[str, str]:
return {"X-CSRF": CSRF}
@pytest_asyncio.fixture
async def ctx() -> AsyncIterator[Ctx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
handle = f"id{uuid.uuid4().hex[:8]}@test.local"
user = User(handle=handle, password_hash=hash_password(PASSWORD), recovery_email="r@x.com")
session.add(user)
await session.commit()
token = await create_session(user.id, "pytest")
app = create_app()
client = httpx.AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
)
try:
yield Ctx(app=app, client=client, user_id=user.id, handle=handle, session=session)
finally:
await client.aclose()
await session.execute(delete(User).where(User.id == user.id))
await session.commit()
await session.close()
await engine.dispose()
def _fresh_client(app: object) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver")
async def test_totp_setup_enable_and_login(ctx: Ctx) -> None:
setup = await ctx.client.post("/api/auth/totp/setup", headers=_hdr())
assert setup.status_code == 200
secret = setup.json()["secret"]
assert setup.json()["provisioning_uri"].startswith("otpauth://")
# wrong code rejected
bad = await ctx.client.post("/api/auth/totp/enable", json={"code": "000000"}, headers=_hdr())
assert bad.status_code == 400
code = pyotp.TOTP(secret).now()
ok = await ctx.client.post("/api/auth/totp/enable", json={"code": code}, headers=_hdr())
assert ok.status_code == 200
# login now requires the 2FA code
async with _fresh_client(ctx.app) as anon:
no_totp = await anon.post(
"/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD}
)
assert no_totp.status_code == 401
assert no_totp.json()["error"]["code"] == "totp_required"
with_totp = await anon.post(
"/api/auth/login",
json={"handle": ctx.handle, "password": PASSWORD, "totp": pyotp.TOTP(secret).now()},
)
assert with_totp.status_code == 200
async def test_totp_disable(ctx: Ctx) -> None:
# enable first
secret = (await ctx.client.post("/api/auth/totp/setup", headers=_hdr())).json()["secret"]
await ctx.client.post(
"/api/auth/totp/enable", json={"code": pyotp.TOTP(secret).now()}, headers=_hdr()
)
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is True
# wrong password is rejected
bad = await ctx.client.post("/api/auth/totp/disable", json={"password": "nope"}, headers=_hdr())
assert bad.status_code == 401
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is True
# correct password disables it
ok = await ctx.client.post(
"/api/auth/totp/disable", json={"password": PASSWORD}, headers=_hdr()
)
assert ok.status_code == 200
assert (await ctx.client.get("/api/auth/me")).json()["totp_enabled"] is False
# login now works without a code
async with _fresh_client(ctx.app) as anon:
resp = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
assert resp.status_code == 200
async def test_sessions_list_and_revoke(ctx: Ctx) -> None:
# create a second session for the same user (a different device)
other_token = await create_session(ctx.user_id, "other-device")
listing = await ctx.client.get("/api/auth/sessions")
assert listing.status_code == 200
rows = listing.json()
assert len(rows) == 2
current = [r for r in rows if r["current"]]
assert len(current) == 1
other_sid = next(r["sid"] for r in rows if not r["current"])
revoked = await ctx.client.request("DELETE", f"/api/auth/sessions/{other_sid}", headers=_hdr())
assert revoked.status_code == 200
# the revoked token no longer resolves
async with _fresh_client(ctx.app) as anon:
anon.cookies.set("session", other_token)
me = await anon.get("/api/auth/me")
assert me.status_code == 401
async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None:
# must not leak whether a handle exists
resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"})
assert resp.status_code == 200