"""Invite a friend: each user sends up to two single-use sign-up links, which work even while registration is closed (app/identity/invitations.py). Runs against the dev database; the settings row is put back as it was and every user made here is removed on the way out. Mail is captured, never sent. """ from __future__ import annotations import base64 import datetime as dt import re import uuid from collections.abc import AsyncIterator from typing import Any import httpx import pytest import pytest_asyncio from httpx import ASGITransport from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from app.api import invitations as invitations_api from app.config import get_settings from app.identity import invitations from app.identity.mailer import MailerError from app.identity.passwords import hash_password from app.identity.sessions import create_session from app.main import create_app from app.models import Account, AppSettings, Invitation, User CSRF = "csrf-token" _SETTINGS = ( "registration_open", "invitations_enabled", "smtp_host", "smtp_from", "public_url", "invite_from", "invite_subject", "invite_template", ) class World: def __init__(self, session: AsyncSession) -> None: self.session = session self.created: list[uuid.UUID] = [] self.handles: list[str] = [] self.sent: list[dict[str, Any]] = [] async def user(self, *, admin: bool = False, name: str = "") -> User: handle = f"inv{uuid.uuid4().hex[:8]}@test.local" user = User(handle=handle, password_hash=hash_password("pw"), is_admin=admin) self.session.add(user) await self.session.flush() if name: self.session.add( Account( user_id=user.id, email=handle, display_name=name, imap_config={}, smtp_config={}, secret_ct=b"x", discovery_src="manual", ) ) await self.session.commit() self.created.append(user.id) return user async def client(self, user: User | None = None) -> httpx.AsyncClient: cookies = {"csrf": CSRF} if user is not None: cookies["session"] = await create_session(user.id, "pytest") return httpx.AsyncClient( transport=ASGITransport(app=create_app()), base_url="http://testserver", cookies=cookies, headers={"X-CSRF": CSRF}, ) async def settings(self, **values: Any) -> None: await self.session.execute(update(AppSettings).where(AppSettings.id == 1).values(**values)) await self.session.commit() @pytest_asyncio.fixture async def world(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[World]: engine = create_async_engine(get_settings().database_url, poolclass=NullPool) session = async_sessionmaker(engine, expire_on_commit=False)() row = await session.scalar(select(AppSettings).where(AppSettings.id == 1)) assert row is not None saved = {name: getattr(row, name) for name in _SETTINGS} w = World(session) async def capture(cfg, to, subject, text, html, **kw): # type: ignore[no-untyped-def] w.sent.append({"to": to, "subject": subject, "text": text, "html": html, **kw}) monkeypatch.setattr(invitations_api, "send_mail", capture) await w.settings( registration_open=False, invitations_enabled=True, smtp_host="smtp.example.com", smtp_from="system@example.com", public_url="https://mail.example.com", invite_from="", invite_subject="", invite_template="", ) try: yield w finally: await session.rollback() await w.settings(**saved) await session.execute(delete(User).where(User.id.in_(w.created))) if w.handles: await session.execute(delete(User).where(User.handle.in_(w.handles))) await session.commit() await session.close() await engine.dispose() def _token(mail: dict[str, Any]) -> str: m = re.search(r"#invite=([A-Za-z0-9_-]+)", mail["text"]) assert m is not None, mail["text"] return m.group(1) async def _register(w: World, token: str | None) -> httpx.Response: localpart = f"new{uuid.uuid4().hex[:8]}" w.handles.append(f"{localpart}@{get_settings().app_domain}") body: dict[str, Any] = {"localpart": localpart, "password": "correct horse battery"} if token is not None: body["invite"] = token async with await w.client() as anon: return await anon.post("/api/auth/register", json=body) async def test_two_invitations_then_none(world: World) -> None: inviter = await world.user(name="Jan Kowalski") async with await world.client(inviter) as c: state = (await c.get("/api/invitations")).json() assert state["available"] is True and state["remaining"] == 2 for i, name in enumerate(["Anna", "Piotr"]): resp = await c.post( "/api/invitations", json={"name": name, "email": f"f{i}@example.org"} ) assert resp.status_code == 200, resp.text assert resp.json()["remaining"] == 0 assert [s["invitee_name"] for s in resp.json()["sent"]] == ["Piotr", "Anna"] third = await c.post("/api/invitations", json={"name": "Ola", "email": "o@example.org"}) assert third.status_code == 409 assert third.json()["error"]["code"] == "no_invitations_left" mail = world.sent[0] assert mail["to"] == "f0@example.org" assert mail["sender"] == "system@example.com" assert mail["reply_to"] == inviter.handle assert mail["subject"] == "Jan Kowalski invites you to Ultimo Email" assert "Hi Anna" in mail["html"] and "https://mail.example.com/register#invite=" in mail["html"] async def test_an_invitation_signs_up_once_while_registration_is_closed(world: World) -> None: inviter = await world.user() async with await world.client(inviter) as c: await c.post("/api/invitations", json={"name": "Anna", "email": "anna@example.org"}) token = _token(world.sent[0]) closed = await _register(world, None) assert closed.status_code == 403 # registration itself stays closed async with await world.client() as anon: check = (await anon.post("/api/auth/invitation/check", json={"token": token})).json() assert check == {"valid": True, "invitee_name": "Anna", "inviter_name": inviter.handle} first = await _register(world, token) assert first.status_code == 200, first.text again = await _register(world, token) assert again.status_code == 400 assert again.json()["error"]["code"] == "invalid_invitation" row = await world.session.scalar(select(Invitation).where(Invitation.inviter_id == inviter.id)) assert row is not None and row.used_at is not None and row.used_by is not None async def test_a_failed_sign_up_keeps_the_invitation(world: World) -> None: inviter = await world.user() async with await world.client(inviter) as c: await c.post("/api/invitations", json={"name": "Anna", "email": "anna@example.org"}) token = _token(world.sent[0]) weak = await world.client() async with weak: resp = await weak.post( "/api/auth/register", json={"localpart": "taken" + uuid.uuid4().hex[:6], "password": "password123"}, ) assert resp.status_code == 400 # refused password: nothing used taken = await _register(world, token) assert taken.status_code == 200 # Already used now; but a sign-up that failed on a taken address must not use it: second_inviter = await world.user() async with await world.client(second_inviter) as c: await c.post("/api/invitations", json={"name": "B", "email": "b@example.org"}) token2 = _token(world.sent[-1]) async with await world.client() as anon: dup = await anon.post( "/api/auth/register", json={ "localpart": world.handles[-1].split("@")[0], "password": "correct horse battery", "invite": token2, }, ) assert dup.status_code == 409 assert (await invitations.find_valid(world.session, token2)) is not None async def test_switched_off_means_no_sending_and_no_signing_up(world: World) -> None: inviter = await world.user() async with await world.client(inviter) as c: await c.post("/api/invitations", json={"name": "Anna", "email": "anna@example.org"}) token = _token(world.sent[0]) await world.settings(invitations_enabled=False) assert (await c.get("/api/invitations")).json()["available"] is False refused = await c.post("/api/invitations", json={"name": "B", "email": "b@example.org"}) assert refused.status_code == 403 assert (await _register(world, token)).status_code == 400 async def test_a_mail_that_could_not_go_out_is_given_back( world: World, monkeypatch: pytest.MonkeyPatch ) -> None: async def failing(*args, **kwargs): # type: ignore[no-untyped-def] raise MailerError("relay refused") monkeypatch.setattr(invitations_api, "send_mail", failing) inviter = await world.user() async with await world.client(inviter) as c: resp = await c.post("/api/invitations", json={"name": "Anna", "email": "a@example.org"}) assert resp.status_code == 502 assert (await c.get("/api/invitations")).json()["remaining"] == 2 async def test_an_expired_unused_invitation_is_given_back(world: World) -> None: inviter = await world.user() async with await world.client(inviter) as c: await c.post("/api/invitations", json={"name": "Anna", "email": "a@example.org"}) await world.session.execute( update(Invitation) .where(Invitation.inviter_id == inviter.id) .values(expires_at=dt.datetime.now(dt.UTC) - dt.timedelta(minutes=1)) ) await world.session.commit() state = (await c.get("/api/invitations")).json() assert state["remaining"] == 2 and state["sent"][0]["expired"] is True assert (await _register(world, _token(world.sent[0]))).status_code == 400 async def test_bad_input_is_refused(world: World) -> None: inviter = await world.user() async with await world.client(inviter) as c: bad = await c.post("/api/invitations", json={"name": "A", "email": "not-an-address"}) assert bad.status_code == 400 blank = await c.post("/api/invitations", json={"name": " ", "email": "a@example.org"}) assert blank.status_code == 400 # --- the email ------------------------------------------------------------------------ def test_names_are_escaped_and_placeholders_filled() -> None: row = AppSettings( invite_subject="{{inviter_name}} → {{invitee_name}}", invite_template="

Hi {{ invitee_name }}

{{inviter_name}}

{{invite_button}}", ) mail = invitations.render( row, invitee_name="Eve", inviter_name='Bob "x"', link="https://m.example/r#invite=t" ) assert "<b>Eve</b>" in mail.html and "Eve" not in mail.html assert 'href="https://m.example/r#invite=t"' in mail.html assert mail.subject == 'Bob "x" → Eve' # a header, not HTML assert "https://m.example/r#invite=t" in mail.text and "

" not in mail.text def test_template_images_travel_inside_the_mail() -> None: png = base64.b64encode(b"\x89PNG\r\n\x1a\nfake").decode() row = AppSettings(invite_template=f'{{{{invite_link}}}}') mail = invitations.render(row, invitee_name="A", inviter_name="B", link="https://x/y") assert len(mail.inline) == 1 and "data:image" not in mail.html assert f'src="cid:{mail.inline[0].cid}"' in mail.html @pytest.mark.parametrize( "html,ok", [ ("

{{invite_link}}

", True), ("

{{invite_button}}

", True), ("

Hello {{invitee_name}}

", False), # no way to accept ("

{{invite_link}} {{password}}

", False), # unknown placeholder ], ) def test_template_checks(html: str, ok: bool) -> None: assert (invitations.template_problem(html) is None) is ok async def test_admin_uploads_previews_and_resets_the_template(world: World) -> None: admin = await world.user(admin=True) async with await world.client(admin) as c: bad = await c.put("/api/admin/invitations/template", json={"html": "

hi

"}) assert bad.status_code == 400 saved = await c.put( "/api/admin/invitations/template", json={ "html": "

Dear {{invitee_name}}, {{invite_button}}

", "subject": "Join {{inviter_name}}", "from_address": "postmaster@example.org", }, ) assert saved.status_code == 200 assert saved.json()["from_address"] == "postmaster@example.org" preview = await c.post( "/api/admin/invitations/preview", json={"subject": saved.json()["subject"], "html": saved.json()["html"]}, ) assert preview.json()["subject"] == "Join Jan Kowalski" assert "Dear Anna Nowak" in preview.json()["html"] reset = await c.put( "/api/admin/invitations/template", json={"html": "", "subject": "", "from_address": ""}, ) assert reset.json()["html"] == "" and reset.json()["default_html"] user = await world.user() async with await world.client(user) as c: assert (await c.get("/api/admin/invitations/template")).status_code == 403 async def test_admin_switch(world: World) -> None: admin = await world.user(admin=True) async with await world.client(admin) as c: off = await c.put("/api/admin/settings", json={"invitations_enabled": False}) assert off.json()["invitations_enabled"] is False on = await c.put("/api/admin/settings", json={"invitations_enabled": True}) assert on.json()["invitations_enabled"] is True