Files
bvandeusenandClaude Opus 5.5 28fa8badcb
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 54s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m28s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m14s
Desktop (Tauri) / Update manifest (push) Successful in 4s
invites: an admin lets one person register while registration stays closed
Until now adding a second person meant re-opening registration to the
whole internet while they signed up (#2939 §1). An admin now makes an
invite in Settings: a link that works once, expires (7 days by default,
1 to 30), and can be pinned to one email address. Only the token's hash
is stored, so the link is shown once.

POST /api/auth/register takes `invite`. Redemption is one conditional
UPDATE inside the transaction that creates the account, so two people
racing one link can't both get in, and a taken email leaves the invite
unused. Every refusal says "invalid or expired invite". The register
page reads ?invite= and opens even while registration is closed.

Refs #5172

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:13:03 -04:00

38 lines
1.2 KiB
Python

"""Invite status and lifetime rules, DB-free. Redemption itself is a conditional
UPDATE and is tested against Postgres in test_integration.py."""
from datetime import datetime, timedelta, timezone
import pytest
from inkwell.invites import DEFAULT_DAYS, MAX_DAYS, lifetime_days, status
from inkwell.models.invite import Invite
NOW = datetime(2026, 10, 7, 12, 0, tzinfo=timezone.utc)
def _invite(**fields) -> Invite:
fields.setdefault("expires_at", NOW + timedelta(days=1))
return Invite(token_hash="x", **fields)
def test_an_untouched_invite_in_date_is_pending():
assert status(_invite(), NOW) == "pending"
def test_an_invite_is_expired_from_its_expiry_instant():
assert status(_invite(expires_at=NOW), NOW) == "expired"
def test_revoked_beats_expired_and_redeemed_beats_both():
assert status(_invite(expires_at=NOW, revoked_at=NOW), NOW) == "revoked"
assert status(_invite(expires_at=NOW, revoked_at=NOW, redeemed_at=NOW), NOW) == "redeemed"
@pytest.mark.parametrize(
"raw, days",
[(None, DEFAULT_DAYS), (1, 1), ("14", 14), (MAX_DAYS, MAX_DAYS), (0, None), (MAX_DAYS + 1, None),
("soon", None), (True, None), (2.5, None)],
)
def test_lifetimes(raw, days):
assert lifetime_days(raw) == days