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
This commit is contained in:
2026-10-06 02:56:08 +02:00
parent e512556703
commit 9fb4d65787
36 changed files with 1147 additions and 19 deletions
+32
View File
@@ -0,0 +1,32 @@
# retoor <retoor@molodetz.nl>
JSON = {"Accept": "application/json"}
def test_gallery_status_requires_admin(anon, member):
assert anon.get("/admin/gallery", headers=JSON).status_code == 401
assert member.get("/admin/gallery", headers=JSON).status_code == 403
assert anon.post("/admin/gallery/resync", headers=JSON).status_code == 401
assert member.post("/admin/gallery/resync", headers=JSON).status_code == 403
def test_gallery_status_reports_counts(admin):
response = admin.get("/admin/gallery", headers=JSON)
assert response.status_code == 200
body = response.json()
assert body["flyers"] >= 1
assert body["memes"] >= 1
assert body["missing"] == []
page = admin.get("/admin/gallery")
assert page.status_code == 200
assert "Resync gallery" in page.text
def test_gallery_resync_reconciles(admin):
response = admin.post("/admin/gallery/resync", headers=JSON)
assert response.status_code == 200
data = response.json()["data"]
assert data["synced"] == data["flyers"] + data["memes"]
assert data["missing"] == []
form = admin.post("/admin/gallery/resync")
assert form.status_code in (302, 303)
assert form.headers["location"] == "/admin/gallery"
+23
View File
@@ -0,0 +1,23 @@
# retoor <retoor@molodetz.nl>
import pytest
from molodetz.docs_prose import DOCS_PAGES, PROSE_DIR, page_source, render_page
def test_every_docs_page_has_source_and_renders():
for page in DOCS_PAGES:
assert (PROSE_DIR / page["file"]).is_file(), page["slug"]
assert page_source(page["slug"]).strip(), page["slug"]
assert render_page(page["slug"]).strip(), page["slug"]
@pytest.mark.parametrize("page", DOCS_PAGES, ids=lambda page: page["slug"])
def test_docs_page_reachability_matches_gate(page, anon, admin):
response = admin.get(f"/docs/{page['slug']}")
assert response.status_code == 200, page["slug"]
assert page["title"] in response.text
anon_response = anon.get(f"/docs/{page['slug']}")
if page.get("admin"):
assert anon_response.status_code == 404, page["slug"]
else:
assert anon_response.status_code == 200, page["slug"]
+260
View File
@@ -0,0 +1,260 @@
# 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
+5 -1
View File
@@ -1,8 +1,12 @@
# retoor <retoor@molodetz.nl>
import re
USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{3,32}$")
def test_people_lists_real_accounts_only(anon):
people = anon.get("/people", headers={"Accept": "application/json"}).json()
names = [person["username"] for person in people.get("people", [])]
assert names[0] == "retoor"
assert set(names) <= {"retoor", "lezer"}
assert {"retoor", "lezer"} <= set(names)
assert all(USERNAME_PATTERN.match(name) for name in names)