feat(users): invite by email — a one-time link where the invitee makes their own account (milestone 496)
The operator's ask: an invite link sent by mail, the key in the URL, and the new user choosing their own username, password and email. - user_invites (migration 0037): the key's sha256 only, the address, the role and days the admin chose, who sent it, used/revoked. - core/invites: create (revokes an older open invite to the address), renew (new key, new clock), find_pending, the four states, signup checks. - /invite/<key>, outside sign-in and throttled like login: the invitee's username, email (prefilled, theirs to change) and password; the account takes the admin's role, the key is used up in the same transaction and they are signed in. A bad key gets one plain message. - Settings › Users: Invite by email (address, role, 1/7/30 days), sent from the admin in their zone; without a mail server, or if sending fails, the link is shown once to copy. Invites not yet used are listed with Resend and Withdraw. - Audit: user.invited, user.invite_resent, user.invite_revoked, user.joined. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
"""Invite a user by email (milestone 496): an invite's states, what the
|
||||
invitee's form must hold, and the email that carries the link."""
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from steward.core import invites, mail
|
||||
from steward.core.api_tokens import hash_token
|
||||
from steward.models.invites import UserInvite
|
||||
from steward.models.users import UserRole
|
||||
|
||||
NOW = datetime(2026, 10, 8, 20, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _invite(**kw):
|
||||
base = dict(email="new@example.com", role=UserRole.viewer, days=7, key_hash="x",
|
||||
created_at=NOW, sent_at=NOW, expires_at=NOW + timedelta(days=7))
|
||||
return UserInvite(**(base | kw))
|
||||
|
||||
|
||||
def test_an_invite_is_pending_until_used_revoked_or_past_its_day():
|
||||
assert invites.state(_invite(), NOW) == "pending"
|
||||
assert invites.state(_invite(), NOW + timedelta(days=7)) == "expired"
|
||||
assert invites.state(_invite(used_at=NOW), NOW) == "used"
|
||||
assert invites.state(_invite(revoked_at=NOW), NOW) == "revoked"
|
||||
# Used wins over expired: the account was made in time.
|
||||
assert invites.state(_invite(used_at=NOW), NOW + timedelta(days=30)) == "used"
|
||||
|
||||
|
||||
def test_a_resend_replaces_the_key_and_restarts_the_clock():
|
||||
invite = _invite(days=1, sent_at=NOW - timedelta(days=3), expires_at=NOW - timedelta(days=2))
|
||||
key = invites.renew(invite)
|
||||
assert invite.key_hash == hash_token(key) and len(key) >= 40
|
||||
assert invites.state(invite) == "pending"
|
||||
assert invite.expires_at - invite.sent_at == timedelta(days=1)
|
||||
assert invites.renew(invite) != key
|
||||
|
||||
|
||||
def test_the_link_carries_the_key_under_steward_s_address():
|
||||
assert invites.link("https://steward.example/", "abc") == "https://steward.example/invite/abc"
|
||||
|
||||
|
||||
def test_the_invitee_s_form_needs_a_name_a_real_address_and_a_matching_password():
|
||||
ok = ("ann", "ann@example.com", "longenough", "longenough")
|
||||
assert invites.signup_problem(*ok) is None
|
||||
for bad in (("", *ok[1:]), (" ann", *ok[1:]), ("a" * 65, *ok[1:]),
|
||||
("ann", "ann@oidc", *ok[2:]), ("ann", "ann@example.com", "short", "short"),
|
||||
("ann", "ann@example.com", "longenough", "different1")):
|
||||
assert invites.signup_problem(*bad), bad
|
||||
|
||||
|
||||
def test_the_invite_email_says_who_what_until_when_and_carries_the_link():
|
||||
url = "https://steward.example/invite/KEY123"
|
||||
subject, text, html = mail.invite_email("bvan <b>", "operator", NOW + timedelta(days=7), url,
|
||||
ZoneInfo("America/New_York"))
|
||||
assert subject == "[Steward] bvan <b> invited you to Steward"
|
||||
for part in (text, html):
|
||||
assert url in part
|
||||
assert "an operator: you can also run playbooks" in part
|
||||
assert "Thu 15 Oct 2026 16:00 EDT" in part and "Thu 15 Oct 2026 20:00 UTC" in part
|
||||
assert "bvan <b>" in html and "<b>" not in html
|
||||
# Nothing in the HTML reaches anywhere but Steward, or loads at all.
|
||||
assert all(u.startswith("https://steward.example") for u in re.findall(r"""https?://[^\s"'<>)]+""", html))
|
||||
assert not re.search(r"""\ssrc\s*=|url\(|<link\b|@import""", html, re.I)
|
||||
Reference in New Issue
Block a user