Private
Public Access
Add admin-invited signups and email notifications (Gitea issue #15)
Site admins can invite a brand-new person by email from the Admin portal Users tab -- a signup-link email lets them set their own username/password and lands them in the app already logged in. Existing users invited to a room now also get an email. Closes the "invited but never notified" gap from both directions. SMTP is configured through the Admin Settings tab at runtime (not the env file), persisted in a new smtp_settings table with the password encrypted at rest via a Fernet key derived from SESSION_SECRET -- the first reversible secret this app stores in the database. A "send test email" button surfaces real delivery errors; the invite/notification paths themselves never fail loudly, since an SMTP outage shouldn't block an action that already succeeded in the database. New site_invites table mirrors RoomInvite's shape but targets an email address with no room context; the raw signup token is hashed the same way API tokens are, and only ever exists in the email link. POST /api/signup is the first genuinely public, unauthenticated account-creation endpoint in this app, reusing the existing register_user path for identical validation.
This commit is contained in:
@@ -1,10 +1,28 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.models import RoomInvite
|
||||
from app.models import RoomInvite, User
|
||||
from tests.conftest import login_as, register_and_login
|
||||
|
||||
|
||||
async def _make_admin(db_session, user_id: str) -> None:
|
||||
user = await db_session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def _configure_smtp(client):
|
||||
resp = await client.put(
|
||||
"/api/admin/settings/smtp",
|
||||
json={
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"from_address": "noreply@example.com",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
async def _create_private_room(client, name="secret"):
|
||||
resp = await client.post("/api/rooms", json={"name": name, "is_private": True})
|
||||
assert resp.status_code == 201, resp.text
|
||||
@@ -171,3 +189,45 @@ async def test_expired_invite_rejected_on_accept(client, db_session):
|
||||
await login_as(client, "bob")
|
||||
resp = await client.post(f"/api/invites/{invite['id']}/accept")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_create_invite_sends_email_to_target(client, db_session, monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_send(message, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
|
||||
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await _make_admin(db_session, alice["id"])
|
||||
await _configure_smtp(client)
|
||||
room = await _create_private_room(client)
|
||||
await register_and_login(client, db_session, username="bob")
|
||||
await login_as(client, "alice")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["hostname"] == "smtp.example.com"
|
||||
|
||||
|
||||
async def test_create_invite_succeeds_even_if_email_delivery_fails(client, db_session, monkeypatch):
|
||||
async def fake_send(message, **kwargs):
|
||||
raise ConnectionRefusedError("boom")
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
|
||||
|
||||
alice = await register_and_login(client, db_session, username="alice")
|
||||
await _make_admin(db_session, alice["id"])
|
||||
await _configure_smtp(client)
|
||||
room = await _create_private_room(client)
|
||||
await register_and_login(client, db_session, username="bob")
|
||||
await login_as(client, "alice")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/rooms/{room['id']}/invites", json={"target_username": "bob"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.models import SiteInvite, User
|
||||
from tests.conftest import login_as, register_and_login
|
||||
|
||||
|
||||
async def _make_admin(db_session, user_id: str) -> None:
|
||||
user = await db_session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
def _fake_smtp(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_send(message, **kwargs):
|
||||
calls.append({"message": message, **kwargs})
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
|
||||
return calls
|
||||
|
||||
|
||||
async def _configure_smtp(client):
|
||||
resp = await client.put(
|
||||
"/api/admin/settings/smtp",
|
||||
json={
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "bot",
|
||||
"password": "secret",
|
||||
"from_address": "noreply@example.com",
|
||||
"use_tls": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
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_create_site_invite_requires_admin(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.post("/api/admin/invites", json={"email": "newperson@example.com"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_signup_flow_end_to_end(client, db_session, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "newperson@example.com"})
|
||||
assert resp.status_code == 201, resp.text
|
||||
invite = resp.json()
|
||||
assert invite["email"] == "newperson@example.com"
|
||||
assert invite["status"] == "pending"
|
||||
|
||||
assert len(calls) == 1
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
|
||||
validate = await client.get(f"/api/signup/validate?token={token}")
|
||||
assert validate.status_code == 200
|
||||
assert validate.json()["email"] == "newperson@example.com"
|
||||
|
||||
complete = await client.post(
|
||||
"/api/signup",
|
||||
json={"token": token, "username": "newperson", "password": "password123"},
|
||||
)
|
||||
assert complete.status_code == 200, complete.text
|
||||
assert complete.json()["email"] == "newperson@example.com"
|
||||
|
||||
me = await client.get("/api/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["username"] == "newperson"
|
||||
|
||||
|
||||
async def test_invalid_token_rejected(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
|
||||
validate = await client.get("/api/signup/validate?token=not-a-real-token")
|
||||
assert validate.status_code == 400
|
||||
|
||||
complete = await client.post(
|
||||
"/api/signup",
|
||||
json={"token": "not-a-real-token", "username": "someone", "password": "password123"},
|
||||
)
|
||||
assert complete.status_code == 400
|
||||
|
||||
|
||||
async def test_expired_token_rejected(client, db_session, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "late@example.com"})
|
||||
invite_id = resp.json()["id"]
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
|
||||
db_invite = await db_session.get(SiteInvite, uuid.UUID(invite_id))
|
||||
db_invite.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
await db_session.commit()
|
||||
|
||||
complete = await client.post(
|
||||
"/api/signup",
|
||||
json={"token": token, "username": "late", "password": "password123"},
|
||||
)
|
||||
assert complete.status_code == 400
|
||||
|
||||
|
||||
async def test_used_token_cannot_be_reused(client, db_session, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
await client.post("/api/admin/invites", json={"email": "once@example.com"})
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
|
||||
first = await client.post(
|
||||
"/api/signup",
|
||||
json={"token": token, "username": "onceuser", "password": "password123"},
|
||||
)
|
||||
assert first.status_code == 200
|
||||
|
||||
second = await client.post(
|
||||
"/api/signup",
|
||||
json={"token": token, "username": "onceuser2", "password": "password123"},
|
||||
)
|
||||
assert second.status_code == 400
|
||||
|
||||
|
||||
async def test_revoke_site_invite_prevents_signup(client, db_session, monkeypatch):
|
||||
calls = _fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
resp = await client.post("/api/admin/invites", json={"email": "revoked@example.com"})
|
||||
invite_id = resp.json()["id"]
|
||||
token = _extract_token(calls[0]["message"].get_content())
|
||||
|
||||
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
|
||||
assert revoke.status_code == 200
|
||||
assert revoke.json()["status"] == "revoked"
|
||||
|
||||
complete = await client.post(
|
||||
"/api/signup",
|
||||
json={"token": token, "username": "revokeduser", "password": "password123"},
|
||||
)
|
||||
assert complete.status_code == 400
|
||||
|
||||
|
||||
async def test_list_site_invites(client, db_session, monkeypatch):
|
||||
_fake_smtp(monkeypatch)
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await _configure_smtp(client)
|
||||
|
||||
await client.post("/api/admin/invites", json={"email": "listed@example.com"})
|
||||
resp = await client.get("/api/admin/invites")
|
||||
assert resp.status_code == 200
|
||||
emails = [i["email"] for i in resp.json()]
|
||||
assert "listed@example.com" in emails
|
||||
@@ -0,0 +1,174 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.crypto import decrypt
|
||||
from app.models import SmtpSettings, User
|
||||
from tests.conftest import register_and_login
|
||||
|
||||
|
||||
async def _get_settings_row(db_session) -> SmtpSettings:
|
||||
result = await db_session.execute(select(SmtpSettings))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def _make_admin(db_session, user_id: str) -> None:
|
||||
user = await db_session.get(User, uuid.UUID(user_id))
|
||||
user.is_site_admin = True
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
async def test_smtp_settings_require_admin(client, db_session):
|
||||
await register_and_login(client, db_session, username="alice")
|
||||
resp = await client.get("/api/admin/settings/smtp")
|
||||
assert resp.status_code == 403
|
||||
|
||||
resp = await client.put(
|
||||
"/api/admin/settings/smtp",
|
||||
json={
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"from_address": "noreply@example.com",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_smtp_settings_get_before_configured(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
|
||||
resp = await client.get("/api/admin/settings/smtp")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() is None
|
||||
|
||||
|
||||
async def test_smtp_settings_update_and_password_never_returned(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
|
||||
resp = await client.put(
|
||||
"/api/admin/settings/smtp",
|
||||
json={
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "bot",
|
||||
"password": "super-secret",
|
||||
"from_address": "noreply@example.com",
|
||||
"use_tls": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "password" not in body
|
||||
assert body["has_password"] is True
|
||||
assert body["host"] == "smtp.example.com"
|
||||
|
||||
get_resp = await client.get("/api/admin/settings/smtp")
|
||||
assert get_resp.status_code == 200
|
||||
assert "password" not in get_resp.json()
|
||||
assert get_resp.json()["has_password"] is True
|
||||
|
||||
|
||||
async def test_smtp_settings_password_encrypted_at_rest(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
|
||||
await client.put(
|
||||
"/api/admin/settings/smtp",
|
||||
json={
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"password": "super-secret",
|
||||
"from_address": "noreply@example.com",
|
||||
},
|
||||
)
|
||||
|
||||
row = await _get_settings_row(db_session)
|
||||
assert row.password_encrypted != "super-secret"
|
||||
assert decrypt(row.password_encrypted) == "super-secret"
|
||||
|
||||
|
||||
async def test_smtp_settings_blank_password_keeps_existing(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
|
||||
await client.put(
|
||||
"/api/admin/settings/smtp",
|
||||
json={
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"password": "first-password",
|
||||
"from_address": "noreply@example.com",
|
||||
},
|
||||
)
|
||||
encrypted_before = (await _get_settings_row(db_session)).password_encrypted
|
||||
|
||||
resp = await client.put(
|
||||
"/api/admin/settings/smtp",
|
||||
json={
|
||||
"host": "smtp.example.com",
|
||||
"port": 2525,
|
||||
"from_address": "noreply@example.com",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["port"] == 2525
|
||||
assert resp.json()["has_password"] is True
|
||||
|
||||
row = await _get_settings_row(db_session)
|
||||
assert row.password_encrypted == encrypted_before
|
||||
|
||||
|
||||
async def test_send_test_email_not_configured(client, db_session):
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
|
||||
resp = await client.post("/api/admin/settings/smtp/test")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_send_test_email_success(client, db_session, monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_send(message, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
|
||||
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await client.put(
|
||||
"/api/admin/settings/smtp",
|
||||
json={
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"from_address": "noreply@example.com",
|
||||
},
|
||||
)
|
||||
|
||||
resp = await client.post("/api/admin/settings/smtp/test")
|
||||
assert resp.status_code == 204
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["hostname"] == "smtp.example.com"
|
||||
|
||||
|
||||
async def test_send_test_email_surfaces_failure(client, db_session, monkeypatch):
|
||||
async def fake_send(message, **kwargs):
|
||||
raise ConnectionRefusedError("boom")
|
||||
|
||||
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
|
||||
|
||||
admin = await register_and_login(client, db_session, username="admin1")
|
||||
await _make_admin(db_session, admin["id"])
|
||||
await client.put(
|
||||
"/api/admin/settings/smtp",
|
||||
json={
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"from_address": "noreply@example.com",
|
||||
},
|
||||
)
|
||||
|
||||
resp = await client.post("/api/admin/settings/smtp/test")
|
||||
assert resp.status_code == 502
|
||||
Reference in New Issue
Block a user