Private
Public Access
Previously the only way to resend was to re-invite the same email from scratch, creating a whole new invite row. Adds a "Resend" action next to Revoke on each pending invite -- rotates the token and refreshes the 7-day expiry on the same row (the old link stops working the moment it's used, same instinct as a password-reset resend), then re-sends the invite email. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
329 lines
12 KiB
Python
329 lines
12 KiB
Python
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)
|
|
|
|
|
|
def _plain_text(message) -> str:
|
|
# #68: the email is now multipart/alternative (HTML + plain-text
|
|
# fallback) -- .get_content() has no handler for a multipart message
|
|
# itself, get_body(preferencelist=...) is the standard way to reach a
|
|
# specific alternative part.
|
|
return message.get_body(preferencelist=("plain",)).get_content()
|
|
|
|
|
|
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(_plain_text(calls[0]["message"]))
|
|
|
|
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", "password_confirm": "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_signup_rejects_mismatched_password_confirmation(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": "typo@example.com"})
|
|
token = _extract_token(_plain_text(calls[0]["message"]))
|
|
|
|
complete = await client.post(
|
|
"/api/signup",
|
|
json={
|
|
"token": token,
|
|
"username": "typouser",
|
|
"password": "password123",
|
|
"password_confirm": "password124",
|
|
},
|
|
)
|
|
assert complete.status_code == 422
|
|
|
|
# The mismatch must not have consumed the invite -- a typo shouldn't
|
|
# burn a single-use token.
|
|
validate = await client.get(f"/api/signup/validate?token={token}")
|
|
assert validate.status_code == 200
|
|
|
|
|
|
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", "password_confirm": "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(_plain_text(calls[0]["message"]))
|
|
|
|
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", "password_confirm": "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(_plain_text(calls[0]["message"]))
|
|
|
|
first = await client.post(
|
|
"/api/signup",
|
|
json={"token": token, "username": "onceuser", "password": "password123", "password_confirm": "password123"},
|
|
)
|
|
assert first.status_code == 200
|
|
|
|
second = await client.post(
|
|
"/api/signup",
|
|
json={"token": token, "username": "onceuser2", "password": "password123", "password_confirm": "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(_plain_text(calls[0]["message"]))
|
|
|
|
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", "password_confirm": "password123"},
|
|
)
|
|
assert complete.status_code == 400
|
|
|
|
|
|
async def test_resend_site_invite_requires_admin(client, db_session):
|
|
await register_and_login(client, db_session, username="alice")
|
|
resp = await client.post(f"/api/admin/invites/{uuid.uuid4()}/resend")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_resend_site_invite_unknown_id_404s(client, db_session):
|
|
admin = await register_and_login(client, db_session, username="admin1")
|
|
await _make_admin(db_session, admin["id"])
|
|
resp = await client.post(f"/api/admin/invites/{uuid.uuid4()}/resend")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_resend_site_invite_new_link_works_and_old_one_doesnt(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": "resend-me@example.com"})
|
|
invite_id = resp.json()["id"]
|
|
original_expires_at = datetime.fromisoformat(resp.json()["expires_at"])
|
|
old_token = _extract_token(_plain_text(calls[0]["message"]))
|
|
|
|
resend = await client.post(f"/api/admin/invites/{invite_id}/resend")
|
|
assert resend.status_code == 200, resend.text
|
|
assert resend.json()["id"] == invite_id
|
|
assert resend.json()["status"] == "pending"
|
|
# A fresh 7-day window, not whatever was left on the original.
|
|
new_expires_at = datetime.fromisoformat(resend.json()["expires_at"])
|
|
assert new_expires_at > original_expires_at
|
|
|
|
assert len(calls) == 2
|
|
new_token = _extract_token(_plain_text(calls[1]["message"]))
|
|
assert new_token != old_token
|
|
|
|
# The old link is dead -- resending rotates the token, it doesn't just
|
|
# repeat it.
|
|
old_validate = await client.get(f"/api/signup/validate?token={old_token}")
|
|
assert old_validate.status_code == 400
|
|
|
|
new_validate = await client.get(f"/api/signup/validate?token={new_token}")
|
|
assert new_validate.status_code == 200
|
|
assert new_validate.json()["email"] == "resend-me@example.com"
|
|
|
|
complete = await client.post(
|
|
"/api/signup",
|
|
json={
|
|
"token": new_token,
|
|
"username": "resentuser",
|
|
"password": "password123",
|
|
"password_confirm": "password123",
|
|
},
|
|
)
|
|
assert complete.status_code == 200, complete.text
|
|
|
|
|
|
async def test_resend_site_invite_rejects_non_pending(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)
|
|
|
|
resp = await client.post("/api/admin/invites", json={"email": "already-revoked@example.com"})
|
|
invite_id = resp.json()["id"]
|
|
|
|
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
|
|
assert revoke.status_code == 200
|
|
|
|
resend = await client.post(f"/api/admin/invites/{invite_id}/resend")
|
|
assert resend.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
|
|
|
|
|
|
async def test_list_site_invites_excludes_revoked_invite(client, db_session, monkeypatch):
|
|
# #61: the admin UI labels this list "Pending invites" -- a revoked
|
|
# invite has nothing left to act on and must actually drop out of it,
|
|
# not just get relabeled in place.
|
|
_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-from-list@example.com"})
|
|
invite_id = resp.json()["id"]
|
|
|
|
revoke = await client.delete(f"/api/admin/invites/{invite_id}")
|
|
assert revoke.status_code == 200
|
|
|
|
listed = await client.get("/api/admin/invites")
|
|
emails = [i["email"] for i in listed.json()]
|
|
assert "revoked-from-list@example.com" not in emails
|
|
|
|
|
|
async def test_list_site_invites_excludes_accepted_invite(client, db_session, monkeypatch):
|
|
# Same gap, the other trigger: completing signup accepts the invite
|
|
# out-of-band from the admin's own session, but it must still be gone
|
|
# from the pending list on the admin's next fetch.
|
|
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": "accepted-from-list@example.com"})
|
|
token = _extract_token(_plain_text(calls[0]["message"]))
|
|
|
|
complete = await client.post(
|
|
"/api/signup",
|
|
json={
|
|
"token": token,
|
|
"username": "acceptedfromlist",
|
|
"password": "password123",
|
|
"password_confirm": "password123",
|
|
},
|
|
)
|
|
assert complete.status_code == 200, complete.text
|
|
|
|
# Signup logs the new user's session in on `client` -- switch back to
|
|
# the admin to check the list the way the admin actually would.
|
|
await login_as(client, "admin1")
|
|
listed = await client.get("/api/admin/invites")
|
|
emails = [i["email"] for i in listed.json()]
|
|
assert "accepted-from-list@example.com" not in emails
|