Files
FabledSteward/tests/core/test_invites.py
T
bvandeusenandClaude Opus 5.5 2b6e12859a
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m17s
CI / unit (push) Successful in 1m43s
CI / publish (push) Skipped
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>
2026-10-08 16:54:44 -04:00

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 &lt;b&gt;" 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)