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:
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user