Private
Public Access
Every email went through one shared plain-text-only path. Redesigned send_email/send_test_email around structured paragraphs + an optional CTA button instead of one pre-formatted string, and render both a proper styled HTML card (table-based, inline styles -- email clients strip <style> blocks and don't support CSS variables) and a clean plain-text fallback from the same input, sent as multipart/alternative. The HTML is themed per recipient: an email to an existing user renders in their own selected theme (dark/light/midnight/sunset, or their saved custom palette), resolved server-side from User.theme/ active_custom_theme_id. Site invites have no account yet to read a theme from, so they use the default DarkSingularity palette. All five existing email triggers (site invite, room-added, password reset, #66's DM notification, admin test email) updated to the new call shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
173 lines
6.0 KiB
Python
173 lines
6.0 KiB
Python
import re
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.models import PasswordReset
|
|
from tests.conftest import register_and_login
|
|
|
|
|
|
def _fake_send_email(monkeypatch):
|
|
calls = []
|
|
|
|
async def fake(db, to, subject, paragraphs, *, cta_label=None, cta_url=None, theme_user=None):
|
|
calls.append(
|
|
{
|
|
"to": to,
|
|
"subject": subject,
|
|
"paragraphs": paragraphs,
|
|
"cta_label": cta_label,
|
|
"cta_url": cta_url,
|
|
"theme_user": theme_user,
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr("app.services.password_service.send_email", fake)
|
|
return calls
|
|
|
|
|
|
def _extract_token(body: str) -> str:
|
|
match = re.search(r"token=([^\s&]+)", body)
|
|
assert match, f"no token found in email body: {body}"
|
|
return match.group(1)
|
|
|
|
|
|
async def test_change_password_requires_auth(client):
|
|
resp = await client.patch(
|
|
"/api/auth/password", json={"current_password": "x", "new_password": "newpassword123"}
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_change_password_wrong_current(client, db_session):
|
|
await register_and_login(client, db_session, username="alice")
|
|
resp = await client.patch(
|
|
"/api/auth/password",
|
|
json={"current_password": "wrong-password", "new_password": "newpassword123"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
async def test_change_password_success(client, db_session):
|
|
await register_and_login(client, db_session, username="alice")
|
|
resp = await client.patch(
|
|
"/api/auth/password",
|
|
json={"current_password": "password123", "new_password": "newpassword123"},
|
|
)
|
|
assert resp.status_code == 204
|
|
|
|
await client.post("/api/auth/logout")
|
|
old = await client.post(
|
|
"/api/auth/login", json={"username_or_email": "alice", "password": "password123"}
|
|
)
|
|
assert old.status_code == 401
|
|
new = await client.post(
|
|
"/api/auth/login", json={"username_or_email": "alice", "password": "newpassword123"}
|
|
)
|
|
assert new.status_code == 200
|
|
|
|
|
|
async def test_change_password_too_short_rejected(client, db_session):
|
|
await register_and_login(client, db_session, username="alice")
|
|
resp = await client.patch(
|
|
"/api/auth/password",
|
|
json={"current_password": "password123", "new_password": "short"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_forgot_password_unknown_email_no_email_sent(client, monkeypatch):
|
|
calls = _fake_send_email(monkeypatch)
|
|
resp = await client.post("/api/auth/forgot-password", json={"email": "nobody@example.com"})
|
|
assert resp.status_code == 204
|
|
assert calls == []
|
|
|
|
|
|
async def test_forgot_password_known_email_sends_email(client, db_session, monkeypatch):
|
|
calls = _fake_send_email(monkeypatch)
|
|
await register_and_login(client, db_session, username="alice")
|
|
await client.post("/api/auth/logout")
|
|
|
|
resp = await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
|
assert resp.status_code == 204
|
|
assert len(calls) == 1
|
|
assert calls[0]["to"] == "alice@example.com"
|
|
|
|
|
|
async def test_reset_password_flow_end_to_end(client, db_session, monkeypatch):
|
|
calls = _fake_send_email(monkeypatch)
|
|
await register_and_login(client, db_session, username="alice")
|
|
await client.post("/api/auth/logout")
|
|
|
|
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
|
token = _extract_token(calls[0]["cta_url"])
|
|
|
|
validate = await client.get(f"/api/auth/reset-password/validate?token={token}")
|
|
assert validate.status_code == 204
|
|
|
|
complete = await client.post(
|
|
"/api/auth/reset-password", json={"token": token, "new_password": "brandnewpass123"}
|
|
)
|
|
assert complete.status_code == 200
|
|
assert complete.json()["username"] == "alice"
|
|
|
|
# completing a reset logs the user in immediately, same as signup
|
|
me = await client.get("/api/auth/me")
|
|
assert me.status_code == 200
|
|
|
|
await client.post("/api/auth/logout")
|
|
old = await client.post(
|
|
"/api/auth/login", json={"username_or_email": "alice", "password": "password123"}
|
|
)
|
|
assert old.status_code == 401
|
|
new = await client.post(
|
|
"/api/auth/login", json={"username_or_email": "alice", "password": "brandnewpass123"}
|
|
)
|
|
assert new.status_code == 200
|
|
|
|
|
|
async def test_reset_password_invalid_token_rejected(client):
|
|
resp = await client.get("/api/auth/reset-password/validate?token=not-a-real-token")
|
|
assert resp.status_code == 400
|
|
|
|
complete = await client.post(
|
|
"/api/auth/reset-password",
|
|
json={"token": "not-a-real-token", "new_password": "newpassword123"},
|
|
)
|
|
assert complete.status_code == 400
|
|
|
|
|
|
async def test_reset_password_expired_token_rejected(client, db_session, monkeypatch):
|
|
calls = _fake_send_email(monkeypatch)
|
|
await register_and_login(client, db_session, username="alice")
|
|
await client.post("/api/auth/logout")
|
|
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
|
token = _extract_token(calls[0]["cta_url"])
|
|
|
|
reset = (await db_session.execute(select(PasswordReset))).scalar_one()
|
|
reset.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
|
await db_session.commit()
|
|
|
|
complete = await client.post(
|
|
"/api/auth/reset-password", json={"token": token, "new_password": "newpassword123"}
|
|
)
|
|
assert complete.status_code == 400
|
|
|
|
|
|
async def test_reset_password_used_token_cannot_be_reused(client, db_session, monkeypatch):
|
|
calls = _fake_send_email(monkeypatch)
|
|
await register_and_login(client, db_session, username="alice")
|
|
await client.post("/api/auth/logout")
|
|
await client.post("/api/auth/forgot-password", json={"email": "alice@example.com"})
|
|
token = _extract_token(calls[0]["cta_url"])
|
|
|
|
first = await client.post(
|
|
"/api/auth/reset-password", json={"token": token, "new_password": "firstpass123"}
|
|
)
|
|
assert first.status_code == 200
|
|
|
|
second = await client.post(
|
|
"/api/auth/reset-password", json={"token": token, "new_password": "secondpass123"}
|
|
)
|
|
assert second.status_code == 400
|