footer sign-in link; sign-in, invite and reset land on the console

One quiet 'Sign in · leaders & families' in the public footer, after the
site's own links and before Join, which stays the only call to action.
Nothing on the public site pointed at /login before; the only ways in
were typing the URL or an invite link. A fresh sign-in, an accepted
invite and a used reset link now land on /leaders/, which sends a
member on to Family and a leader to Summary. tests/smoke_identity.py
123 -> 125.
This commit is contained in:
2026-09-04 20:39:45 -04:00
parent 752fb08d73
commit 77dd250436
4 changed files with 19 additions and 11 deletions
+15 -7
View File
@@ -266,6 +266,14 @@ kinds = {r["kind"] for r in I.list_events(limit=500)}
check("people actions in the log", {"invite.revoked", "person.roles", "person.disabled", "person.enabled",
"password.reset_issued", "password.reset", "password.changed"} <= kinds)
print("\nfooter sign-in")
import os as _os
_src = open(_os.path.join(_os.path.dirname(__file__), "..", "app", "app.py")).read()
_foot = _src[_src.find("<footer"):_src.find("</footer>")]
check("public footer carries one quiet sign-in link before Join", _foot.count('href="/login"') == 1 and _foot.index('href="/login"') < _foot.index('href="/join"'))
check("sign-in lands on the console by default; unsafe next still refused",
I.safe_next("") == "/leaders/" and I.safe_next("//evil") == "/leaders/" and I.safe_next("/leaders/roster") == "/leaders/roster")
print("\nmeeting words")
D = dict(MEETING_DAY="Tuesday", MEETING_DAYS="Tuesdays", MEETING_DAY_ABBR="Tue", PACK_TIME="6:00 PM",
TROOP_TIME="7:30 PM", PACK_CLOCK="6:00", TROOP_CLOCK="7:30")
@@ -288,13 +296,13 @@ check("live seed rows reproduce the constants", I.meeting_words(I.list_units(),
print("\nsafe_next")
check("relative path passes", I.safe_next("/leaders/") == "/leaders/")
check("query string kept", I.safe_next("/leaders/nearby?x=1") == "/leaders/nearby?x=1")
check("empty falls back", I.safe_next("") == "/account")
check("None falls back", I.safe_next(None) == "/account")
check("absolute URL rejected", I.safe_next("https://evil.example/") == "/account")
check("protocol-relative rejected", I.safe_next("//evil.example/") == "/account")
check("backslash form rejected", I.safe_next("/\\evil.example") == "/account")
check("control char rejected", I.safe_next("/leaders\r\nX: y") == "/account")
check("no leading slash rejected", I.safe_next("leaders/") == "/account")
check("empty falls back", I.safe_next("") == "/leaders/")
check("None falls back", I.safe_next(None) == "/leaders/")
check("absolute URL rejected", I.safe_next("https://evil.example/") == "/leaders/")
check("protocol-relative rejected", I.safe_next("//evil.example/") == "/leaders/")
check("backslash form rejected", I.safe_next("/\\evil.example") == "/leaders/")
check("control char rejected", I.safe_next("/leaders\r\nX: y") == "/leaders/")
check("no leading slash rejected", I.safe_next("leaders/") == "/leaders/")
check("custom default honoured", I.safe_next("nope", default="/") == "/")
print("\n%d passed, %d failed" % (PASS, FAIL))