"""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 ", "operator", NOW + timedelta(days=7), url, ZoneInfo("America/New_York")) assert subject == "[Steward] bvan 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 "" 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\(|