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>
65 lines
3.0 KiB
Python
65 lines
3.0 KiB
Python
"""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)
|