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>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""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
|
||||
@@ -1,4 +1,5 @@
|
||||
"""M1 identity: 2FA (TOTP), session list/revoke, password reset (SPEC §6.1/§9.1)."""
|
||||
"""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
|
||||
|
||||
@@ -19,7 +20,6 @@ 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
|
||||
from app.redis_client import get_redis
|
||||
|
||||
CSRF = "csrf-token"
|
||||
PASSWORD = "hunter2pass"
|
||||
@@ -145,43 +145,6 @@ async def test_sessions_list_and_revoke(ctx: Ctx) -> None:
|
||||
assert me.status_code == 401
|
||||
|
||||
|
||||
async def test_password_reset_flow(ctx: Ctx) -> None:
|
||||
# request a reset; token is stored in redis (not returned)
|
||||
req = await ctx.client.post("/api/auth/password/reset-request", json={"handle": ctx.handle})
|
||||
assert req.status_code == 200
|
||||
|
||||
redis = get_redis()
|
||||
keys = await redis.keys("pwreset:*")
|
||||
# Pick the token bound to THIS user (redis DB may hold others from prior runs).
|
||||
token = None
|
||||
for key in keys:
|
||||
if await redis.get(key) == str(ctx.user_id):
|
||||
token = key.split(":", 1)[1]
|
||||
break
|
||||
assert token is not None, "a reset token should have been issued for this user"
|
||||
|
||||
new_password = "brandNewPass9"
|
||||
reset = await ctx.client.post(
|
||||
"/api/auth/password/reset", json={"token": token, "password": new_password}
|
||||
)
|
||||
assert reset.status_code == 200
|
||||
|
||||
# token is single-use
|
||||
again = await ctx.client.post(
|
||||
"/api/auth/password/reset", json={"token": token, "password": new_password}
|
||||
)
|
||||
assert again.status_code == 400
|
||||
|
||||
# new password works, old one does not
|
||||
async with _fresh_client(ctx.app) as anon:
|
||||
good = await anon.post(
|
||||
"/api/auth/login", json={"handle": ctx.handle, "password": new_password}
|
||||
)
|
||||
assert good.status_code == 200
|
||||
bad = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
|
||||
assert bad.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"})
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Password reset by email, recovery address, and the system mailer (SPEC §6.1).
|
||||
|
||||
The SMTP send is replaced with a recorder, so nothing leaves the machine; the
|
||||
reset link it would have sent is read back from what was recorded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import httpx
|
||||
import pyotp
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.api import admin as admin_api
|
||||
from app.config import get_settings
|
||||
from app.identity import routes
|
||||
from app.identity.mailer import MailerConfig
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.reset import MAX_TOTP_FAILURES
|
||||
from app.identity.service import app_settings
|
||||
from app.identity.sessions import create_session
|
||||
from app.identity.totp import encrypt_totp_secret
|
||||
from app.main import create_app
|
||||
from app.models import AppSettings, User
|
||||
from app.redis_client import get_redis
|
||||
|
||||
CSRF = "csrf-token"
|
||||
PASSWORD = "hunter2pass"
|
||||
NEW_PASSWORD = "brandNewPass9"
|
||||
CFG = MailerConfig(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
security="starttls",
|
||||
username="postmaster@example.com",
|
||||
password="pw",
|
||||
sender="postmaster@example.com",
|
||||
public_url="https://mail.example.com",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sent:
|
||||
mails: list[tuple[str, str, str]] = field(default_factory=list) # (to, subject, text)
|
||||
|
||||
def token(self) -> str:
|
||||
text = self.mails[-1][2]
|
||||
link = next(w for w in text.split() if w.startswith("https://mail.example.com/"))
|
||||
assert link.startswith("https://mail.example.com/reset-password#token=")
|
||||
return link.split("#token=", 1)[1]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ctx:
|
||||
session: AsyncSession
|
||||
anon: httpx.AsyncClient
|
||||
sent: Sent
|
||||
created: list[uuid.UUID] = field(default_factory=list)
|
||||
|
||||
async def user(
|
||||
self, *, recovery: str | None = "me@elsewhere.example", admin: bool = False
|
||||
) -> User:
|
||||
user = User(
|
||||
handle=f"pr{uuid.uuid4().hex[:8]}@test.local",
|
||||
password_hash=hash_password(PASSWORD),
|
||||
recovery_email=recovery,
|
||||
is_admin=admin,
|
||||
)
|
||||
self.session.add(user)
|
||||
await self.session.commit()
|
||||
self.created.append(user.id)
|
||||
await get_redis().delete(f"pwreset-rate:{user.id}")
|
||||
return user
|
||||
|
||||
async def signed_in(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 ctx(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[Ctx]:
|
||||
sent = Sent()
|
||||
|
||||
async def fake_send(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None:
|
||||
sent.mails.append((to, subject, text))
|
||||
|
||||
monkeypatch.setattr(routes, "send_mail", fake_send)
|
||||
monkeypatch.setattr(admin_api, "send_mail", fake_send)
|
||||
monkeypatch.setattr(routes, "mailer_config", lambda row: CFG)
|
||||
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
session = async_sessionmaker(engine, expire_on_commit=False)()
|
||||
anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t")
|
||||
c = Ctx(session=session, anon=anon, sent=sent)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
await anon.aclose()
|
||||
await session.execute(delete(User).where(User.id.in_(c.created)))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _request(ctx: Ctx, handle: str) -> httpx.Response:
|
||||
return await ctx.anon.post("/api/auth/password/reset-request", json={"handle": handle})
|
||||
|
||||
|
||||
async def _login(ctx: Ctx, handle: str, password: str) -> int:
|
||||
resp = await ctx.anon.post("/api/auth/login", json={"handle": handle, "password": password})
|
||||
return resp.status_code
|
||||
|
||||
|
||||
# --- requesting a link -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_request_mails_a_one_time_link_to_the_recovery_address(ctx: Ctx) -> None:
|
||||
user = await ctx.user()
|
||||
assert (await _request(ctx, user.handle)).status_code == 200
|
||||
assert [m[0] for m in ctx.sent.mails] == ["me@elsewhere.example"]
|
||||
token = ctx.sent.token()
|
||||
|
||||
check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
|
||||
assert check.json() == {"valid": True, "totp_required": False}
|
||||
|
||||
reset = await ctx.anon.post(
|
||||
"/api/auth/password/reset", json={"token": token, "password": NEW_PASSWORD}
|
||||
)
|
||||
assert reset.status_code == 200
|
||||
# Used up: a second go fails, and the page is told so up front.
|
||||
again = await ctx.anon.post(
|
||||
"/api/auth/password/reset", json={"token": token, "password": "anotherPass12"}
|
||||
)
|
||||
assert again.json()["error"]["code"] == "invalid_token"
|
||||
check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
|
||||
assert check.json()["valid"] is False
|
||||
|
||||
assert await _login(ctx, user.handle, NEW_PASSWORD) == 200
|
||||
assert await _login(ctx, user.handle, PASSWORD) == 401
|
||||
|
||||
|
||||
async def test_the_answer_never_says_whether_the_account_exists(ctx: Ctx) -> None:
|
||||
no_recovery = await ctx.user(recovery=None)
|
||||
for handle in ("nobody-here-at-all", no_recovery.handle):
|
||||
resp = await _request(ctx, handle)
|
||||
assert resp.status_code == 200 and resp.json() == {"status": "ok"}
|
||||
assert ctx.sent.mails == []
|
||||
|
||||
|
||||
async def test_no_mail_while_system_mail_is_not_set_up(
|
||||
ctx: Ctx, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(routes, "mailer_config", lambda row: None)
|
||||
user = await ctx.user()
|
||||
assert (await _request(ctx, user.handle)).status_code == 200
|
||||
assert ctx.sent.mails == []
|
||||
|
||||
|
||||
async def test_requests_are_capped_per_hour(ctx: Ctx) -> None:
|
||||
user = await ctx.user()
|
||||
for _ in range(5):
|
||||
assert (await _request(ctx, user.handle)).status_code == 200
|
||||
assert len(ctx.sent.mails) == 3
|
||||
|
||||
|
||||
# --- two-step verification ---------------------------------------------------------
|
||||
|
||||
|
||||
async def test_2fa_code_is_required_before_the_password_changes(ctx: Ctx) -> None:
|
||||
user = await ctx.user()
|
||||
secret = pyotp.random_base32()
|
||||
await ctx.session.execute(
|
||||
update(User).where(User.id == user.id).values(totp_secret=encrypt_totp_secret(secret))
|
||||
)
|
||||
await ctx.session.commit()
|
||||
await _request(ctx, user.handle)
|
||||
token = ctx.sent.token()
|
||||
body = {"token": token, "password": NEW_PASSWORD}
|
||||
|
||||
check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
|
||||
assert check.json()["totp_required"] is True
|
||||
missing = await ctx.anon.post("/api/auth/password/reset", json=body)
|
||||
assert missing.json()["error"]["code"] == "totp_required"
|
||||
wrong = await ctx.anon.post("/api/auth/password/reset", json={**body, "totp": "000000"})
|
||||
assert wrong.json()["error"]["code"] == "totp_invalid"
|
||||
# Nothing changed yet, and the link still works.
|
||||
assert await _login(ctx, user.handle, PASSWORD) == 401 # 2FA account: needs a code
|
||||
good = await ctx.anon.post(
|
||||
"/api/auth/password/reset", json={**body, "totp": pyotp.TOTP(secret).now()}
|
||||
)
|
||||
assert good.status_code == 200
|
||||
login = await ctx.anon.post(
|
||||
"/api/auth/login",
|
||||
json={"handle": user.handle, "password": NEW_PASSWORD, "totp": pyotp.TOTP(secret).now()},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
|
||||
|
||||
async def test_too_many_wrong_codes_burn_the_link(ctx: Ctx) -> None:
|
||||
user = await ctx.user()
|
||||
secret = pyotp.random_base32()
|
||||
await ctx.session.execute(
|
||||
update(User).where(User.id == user.id).values(totp_secret=encrypt_totp_secret(secret))
|
||||
)
|
||||
await ctx.session.commit()
|
||||
await _request(ctx, user.handle)
|
||||
token = ctx.sent.token()
|
||||
body = {"token": token, "password": NEW_PASSWORD, "totp": "000000"}
|
||||
for _ in range(MAX_TOTP_FAILURES):
|
||||
await ctx.anon.post("/api/auth/password/reset", json=body)
|
||||
late = await ctx.anon.post(
|
||||
"/api/auth/password/reset", json={**body, "totp": pyotp.TOTP(secret).now()}
|
||||
)
|
||||
assert late.json()["error"]["code"] == "invalid_token"
|
||||
|
||||
|
||||
# --- the recovery address ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_changing_the_recovery_email_takes_the_password(ctx: Ctx) -> None:
|
||||
user = await ctx.user(recovery=None)
|
||||
async with await ctx.signed_in(user) as c:
|
||||
wrong = await c.put(
|
||||
"/api/auth/recovery-email",
|
||||
json={"password": "not-it", "recovery_email": "new@example.com"},
|
||||
)
|
||||
assert wrong.status_code == 403
|
||||
bad = await c.put(
|
||||
"/api/auth/recovery-email", json={"password": PASSWORD, "recovery_email": "nope"}
|
||||
)
|
||||
assert bad.json()["error"]["code"] == "invalid_email"
|
||||
ok = await c.put(
|
||||
"/api/auth/recovery-email",
|
||||
json={"password": PASSWORD, "recovery_email": " New@Example.com "},
|
||||
)
|
||||
assert ok.json()["recovery_email"] == "new@example.com"
|
||||
removed = await c.put(
|
||||
"/api/auth/recovery-email", json={"password": PASSWORD, "recovery_email": ""}
|
||||
)
|
||||
assert removed.json()["recovery_email"] is None
|
||||
|
||||
|
||||
# --- admin: system mail settings -----------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def restore_settings(ctx: Ctx) -> AsyncIterator[None]:
|
||||
row = await app_settings(ctx.session)
|
||||
saved = {
|
||||
k: getattr(row, k)
|
||||
for k in (
|
||||
"smtp_host",
|
||||
"smtp_port",
|
||||
"smtp_security",
|
||||
"smtp_username",
|
||||
"smtp_secret_ct",
|
||||
"smtp_from",
|
||||
"public_url",
|
||||
)
|
||||
}
|
||||
await ctx.session.commit()
|
||||
yield
|
||||
await ctx.session.execute(update(AppSettings).values(**saved))
|
||||
await ctx.session.commit()
|
||||
|
||||
|
||||
async def test_admin_configures_system_mail_without_ever_seeing_the_password(
|
||||
ctx: Ctx, restore_settings: None
|
||||
) -> None:
|
||||
admin = await ctx.user(admin=True)
|
||||
async with await ctx.signed_in(admin) as c:
|
||||
resp = await c.put(
|
||||
"/api/admin/mail",
|
||||
json={
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_port": 465,
|
||||
"smtp_security": "ssl",
|
||||
"smtp_username": "postmaster@example.com",
|
||||
"smtp_password": "s3cret",
|
||||
"smtp_from": "postmaster@example.com",
|
||||
"public_url": "https://mail.example.com/",
|
||||
},
|
||||
)
|
||||
out = resp.json()
|
||||
assert out["has_password"] is True and out["configured"] is True
|
||||
assert out["public_url"] == "https://mail.example.com"
|
||||
assert "s3cret" not in resp.text
|
||||
# Omitting the password keeps it; an empty one removes it.
|
||||
kept = (await c.put("/api/admin/mail", json={"smtp_port": 587})).json()
|
||||
assert kept["has_password"] is True
|
||||
gone = (await c.put("/api/admin/mail", json={"smtp_password": ""})).json()
|
||||
assert gone["has_password"] is False
|
||||
bad = await c.put("/api/admin/mail", json={"public_url": "mail.example.com"})
|
||||
assert bad.json()["error"]["code"] == "invalid_url"
|
||||
test = await c.post("/api/admin/mail/test", json={"to": "me@example.com"})
|
||||
assert test.status_code == 200
|
||||
assert ctx.sent.mails[-1][:2] == ("me@example.com", "Ultimo Email test message")
|
||||
Reference in New Issue
Block a user