- 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
261 lines
8.7 KiB
Python
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
|