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
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>
38 lines
1.2 KiB
Python
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
|