"Cerrar sesión del todo" linked straight to /user/logout on the account
server. That route is POST-only — verified in Gitea 1.22's router, which
registers m.Post("/logout", auth.SignOut) and no GET at all — so the
click was a GET, the answer was 404, and the session it promised to end
carried on untouched. It shipped in 724dada, in the commit whose message
was about not overstating what Salir does.
It cannot be repaired by turning the link into a form: the POST needs a
CSRF token belonging to that other domain, unreadable from here by
design. So the page stops pretending and gives the instruction — go
there, open the profile menu, choose Cerrar sesión — which is what the
small print underneath already said.
server-setup.md contained the whole answer and contradicted itself: one
paragraph states the logout is POST-only "so a link cannot trigger it",
and two paragraphs later promises "the link that finishes the job". The
code followed the wrong half.
Worse, a test asserted "/user/logout" in page, so the suite was
enforcing the defect rather than catching it. That assertion is now
inverted, and a template guard fails the build if any template links
there again.
199 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
99 lines
4.4 KiB
Python
99 lines
4.4 KiB
Python
"""Guards on the templates themselves.
|
|
|
|
These check one thing that no request-level test can: the Content-Security-
|
|
Policy makes a whole category of markup silently inert rather than broken, so
|
|
nothing at runtime will ever fail to tell you about it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
TEMPLATES = sorted((Path(__file__).resolve().parents[1] / "templates").glob("*.html"))
|
|
INLINE_HANDLER = re.compile(r"\son[a-z]+\s*=", re.IGNORECASE)
|
|
|
|
|
|
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
|
|
def test_no_inline_event_handlers(template):
|
|
"""`default-src 'self'` with no 'unsafe-inline' means the browser ignores
|
|
an onsubmit="" attribute without complaining. A delete button written that
|
|
way loses its confirmation dialog and nobody finds out until something is
|
|
deleted by accident. Use data-confirm, handled in static/board.js."""
|
|
found = INLINE_HANDLER.findall(template.read_text(encoding="utf-8"))
|
|
assert not found, f"{template.name} has inline handler(s): {found}"
|
|
|
|
|
|
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
|
|
def test_every_post_form_carries_a_csrf_token(template):
|
|
"""The hook in app.py rejects a POST without one, so a form that forgets it
|
|
is a button that always fails — and fails with a 400 that reads like the
|
|
page is broken rather than like a missing field."""
|
|
html = template.read_text(encoding="utf-8")
|
|
forms = re.findall(r"<form[^>]*method=[\"']post[\"'][^>]*>(.*?)</form>", html,
|
|
re.IGNORECASE | re.DOTALL)
|
|
for form in forms:
|
|
assert "csrf_token" in form, f"{template.name} has a POST form without a CSRF token"
|
|
|
|
|
|
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
|
|
def test_nothing_links_to_the_account_server_logout(template):
|
|
"""A link there is a GET, and that route is POST-only, so the browser gets
|
|
a 404 and the session it was meant to end carries on. We shipped exactly
|
|
that and it went unnoticed for a week, because a dead link on a page nobody
|
|
reaches twice looks like nothing at all.
|
|
|
|
It cannot be fixed by turning the link into a form either: the POST needs a
|
|
CSRF token belonging to that other domain, which is unreadable from here by
|
|
design. The page has to tell the member what to do instead."""
|
|
html = JINJA_COMMENT.sub("", template.read_text(encoding="utf-8"))
|
|
assert "/user/logout" not in html, (
|
|
f"{template.name} links to /user/logout, which answers 404 to a GET"
|
|
)
|
|
|
|
|
|
# --- the name of the software behind the login ---------------------------
|
|
#
|
|
# Members sign in through an OAuth provider that happens to be Gitea. They are
|
|
# never told so: as far as anyone using vienalatina.com is concerned there is
|
|
# one site, and a stray brand name in an error message is the seam showing.
|
|
# Comments and docstrings are exempt — the code has to stay honest about what
|
|
# it talks to, and neither reaches a browser.
|
|
|
|
JINJA_COMMENT = re.compile(r"\{#.*?#\}", re.DOTALL)
|
|
|
|
|
|
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
|
|
def test_no_template_shows_the_name_of_the_account_server(template):
|
|
body = JINJA_COMMENT.sub("", template.read_text(encoding="utf-8"))
|
|
assert "Gitea" not in body, (
|
|
f"{template.name} shows 'Gitea' to the member; call it el servidor de "
|
|
"cuentas, or put the remark in a {# Jinja comment #}"
|
|
)
|
|
|
|
|
|
def test_no_message_in_the_code_shows_it_either():
|
|
"""String literals only, docstrings excluded, and case-sensitive on
|
|
purpose: `gitea_login` and `gitea_tokens` are column names nobody sees, so
|
|
only the capitalised prose form is worth failing on."""
|
|
import ast
|
|
|
|
offenders = []
|
|
for path in sorted(Path(__file__).resolve().parents[1].glob("*.py")):
|
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
docstrings = {
|
|
doc for node in ast.walk(tree)
|
|
if isinstance(node, (ast.Module, ast.FunctionDef,
|
|
ast.AsyncFunctionDef, ast.ClassDef))
|
|
and (doc := ast.get_docstring(node, clean=False))
|
|
}
|
|
offenders += [
|
|
f"{path.name}:{node.lineno}: {node.value!r}"
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
|
and "Gitea" in node.value and node.value not in docstrings
|
|
]
|
|
assert not offenders, "\n".join(offenders)
|