Files
ad/tests/api/invite.py
T
retoor 9fb4d65787 Member invites, gallery resync, content and operator docs
- Invite flow: admin issue/revoke on join requests, public single-use
  claim links (hash-only tokens, 7-day expiry, no state reveal), claim
  creates the Member account and marks the request accepted
- Gallery: admin status page plus resync endpoint; sync refreshes
  thumbnails whose content changed; tools/gallery contract and checker
- Docs: public content page, admin-only operator runbook, api.md
  invite/gallery sections, all routes in the live API docs, docs
  reachability gate test
- Screenshots cover the new pages; version 1.0.18
2026-10-06 02:56:08 +02:00

261 lines
8.7 KiB
Python

# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
JSON = {"Accept": "application/json"}
INVALID_MESSAGE = "This invite link is invalid or has expired."
def _join_uid(admin, name, contact):
response = admin.post("/join", json={"name": name, "contact": contact}, headers=JSON)
assert response.status_code == 200
rows = admin.get("/admin/joins", headers=JSON).json()["requests"]
return next(row for row in rows if row["name"] == name)["uid"]
def _issue(admin, uid):
response = admin.post(f"/admin/joins/{uid}/invite", headers=JSON)
assert response.status_code == 200, response.text[:300]
return response.json()["data"]
def _claim(anon, token, payload):
return anon.post(f"/invite/{token}", json=payload, headers=JSON)
def test_invite_full_claim_flow(anon, admin):
uid = _join_uid(admin, "Invitee One", "invitee.one@example.invalid")
issued = _issue(admin, uid)
token = issued["claim_url"].rsplit("/", 1)[-1]
page = anon.get(f"/invite/{token}")
assert page.status_code == 200
assert "Accept your invite" in page.text
claimed = _claim(
anon,
token,
{
"username": "invitee_one",
"email": "invitee.one@example.invalid",
"password": "secret-one-1",
"password_confirm": "secret-one-1",
"terms": True,
},
)
assert claimed.status_code == 200, claimed.text[:300]
assert claimed.json()["data"]["username"] == "invitee_one"
assert "session" in anon.cookies
rows = admin.get("/admin/joins", headers=JSON).json()["requests"]
row = next(row for row in rows if row["uid"] == uid)
assert row["status"] == "accepted"
assert row["invite_open"] is False
again = _claim(
anon,
token,
{
"username": "invitee_other",
"email": "invitee.one@example.invalid",
"password": "secret-one-2",
"password_confirm": "secret-one-2",
"terms": True,
},
)
assert again.status_code == 404
assert again.json()["error"]["message"] == INVALID_MESSAGE
def test_invite_issue_requires_admin(anon, member, admin):
uid = _join_uid(admin, "Invitee Two", "invitee.two@example.invalid")
assert anon.post(f"/admin/joins/{uid}/invite", headers=JSON).status_code == 401
assert member.post(f"/admin/joins/{uid}/invite", headers=JSON).status_code == 403
assert anon.post(f"/admin/joins/{uid}/invite/revoke", headers=JSON).status_code == 401
def test_invite_declined_request_is_refused(admin):
uid = _join_uid(admin, "Invitee Three", "invitee.three@example.invalid")
declined = admin.post(f"/admin/joins/{uid}/status", json={"status": "declined"}, headers=JSON)
assert declined.status_code == 200
refused = admin.post(f"/admin/joins/{uid}/invite", headers=JSON)
assert refused.status_code == 409
def test_invite_wrong_email_is_rejected(anon, admin):
uid = _join_uid(admin, "Invitee Four", "invitee.four@example.invalid")
token = _issue(admin, uid)["claim_url"].rsplit("/", 1)[-1]
response = _claim(
anon,
token,
{
"username": "invitee_four",
"email": "someone.else@example.invalid",
"password": "secret-four-1",
"password_confirm": "secret-four-1",
"terms": True,
},
)
assert response.status_code == 400
assert "different email address" in response.json()["error"]["message"]
def test_invite_expired_token_reveals_nothing(anon, admin):
from molodetz.database import db
uid = _join_uid(admin, "Invitee Five", "invitee.five@example.invalid")
issued = _issue(admin, uid)
token = issued["claim_url"].rsplit("/", 1)[-1]
past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
with db:
db.query("UPDATE member_invites SET expires_at = :past WHERE join_request_uid = :uid", past=past, uid=uid)
page = anon.get(f"/invite/{token}")
assert page.status_code == 404
assert INVALID_MESSAGE in page.text
response = _claim(
anon,
token,
{
"username": "invitee_five",
"email": "invitee.five@example.invalid",
"password": "secret-five-1",
"password_confirm": "secret-five-1",
"terms": True,
},
)
assert response.status_code == 404
assert response.json()["error"]["message"] == INVALID_MESSAGE
def test_invite_unknown_token_reveals_nothing(anon):
page = anon.get("/invite/does-not-exist")
assert page.status_code == 404
assert INVALID_MESSAGE in page.text
response = _claim(
anon,
"does-not-exist",
{"username": "ghost_user", "email": "ghost@example.invalid", "password": "secret-1", "password_confirm": "secret-1", "terms": True},
)
assert response.status_code == 404
assert response.json()["error"]["message"] == INVALID_MESSAGE
def test_invite_revoke_kills_token(anon, admin):
uid = _join_uid(admin, "Invitee Six", "invitee.six@example.invalid")
token = _issue(admin, uid)["claim_url"].rsplit("/", 1)[-1]
revoked = admin.post(f"/admin/joins/{uid}/invite/revoke", headers=JSON)
assert revoked.status_code == 200
assert revoked.json()["data"]["revoked"] == 1
response = _claim(
anon,
token,
{
"username": "invitee_six",
"email": "invitee.six@example.invalid",
"password": "secret-six-1",
"password_confirm": "secret-six-1",
"terms": True,
},
)
assert response.status_code == 404
def test_invite_reissue_revokes_previous(anon, admin):
uid = _join_uid(admin, "Invitee Seven", "invitee.seven@example.invalid")
first = _issue(admin, uid)["claim_url"].rsplit("/", 1)[-1]
second = _issue(admin, uid)["claim_url"].rsplit("/", 1)[-1]
assert first != second
stale = _claim(
anon,
first,
{
"username": "invitee_seven",
"email": "invitee.seven@example.invalid",
"password": "secret-seven-1",
"password_confirm": "secret-seven-1",
"terms": True,
},
)
assert stale.status_code == 404
fresh = _claim(
anon,
second,
{
"username": "invitee_seven",
"email": "invitee.seven@example.invalid",
"password": "secret-seven-1",
"password_confirm": "secret-seven-1",
"terms": True,
},
)
assert fresh.status_code == 200
def test_invite_claim_validation(anon, admin):
uid = _join_uid(admin, "Invitee Eight", "invitee.eight@example.invalid")
token = _issue(admin, uid)["claim_url"].rsplit("/", 1)[-1]
short = _claim(
anon,
token,
{
"username": "invitee_eight",
"email": "invitee.eight@example.invalid",
"password": "short",
"password_confirm": "short",
"terms": True,
},
)
assert short.status_code == 422
mismatch = _claim(
anon,
token,
{
"username": "invitee_eight",
"email": "invitee.eight@example.invalid",
"password": "secret-eight-1",
"password_confirm": "secret-eight-2",
"terms": True,
},
)
assert mismatch.status_code == 400
no_terms = _claim(
anon,
token,
{
"username": "invitee_eight",
"email": "invitee.eight@example.invalid",
"password": "secret-eight-1",
"password_confirm": "secret-eight-1",
"terms": False,
},
)
assert no_terms.status_code == 400
assert "house rules" in no_terms.json()["error"]["message"]
def test_invite_html_claim_flow(anon, admin):
uid = _join_uid(admin, "Invitee Nine", "invitee.nine@example.invalid")
token = _issue(admin, uid)["claim_url"].rsplit("/", 1)[-1]
bad_form = anon.post(
f"/invite/{token}",
data={
"username": "x",
"email": "invitee.nine@example.invalid",
"password": "secret-nine-1",
"password_confirm": "secret-nine-1",
"terms": "true",
},
)
assert bad_form.status_code == 400
assert "Accept your invite" in bad_form.text
response = anon.post(
f"/invite/{token}",
data={
"username": "invitee_nine",
"email": "invitee.nine@example.invalid",
"password": "secret-nine-1",
"password_confirm": "secret-nine-1",
"terms": "true",
},
)
assert response.status_code in (302, 303)
assert "session" in anon.cookies
used = anon.get(f"/invite/{token}")
assert used.status_code == 404
assert INVALID_MESSAGE in used.text