"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
152 lines
5.7 KiB
Python
152 lines
5.7 KiB
Python
"""Who gets in, and who does not."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from apps.board import gitea
|
|
|
|
PROTECTED = [
|
|
"/comunidad/",
|
|
"/comunidad/nuevo",
|
|
"/comunidad/miembros",
|
|
"/comunidad/miembros/nuevo",
|
|
"/comunidad/mis-datos",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("path", PROTECTED)
|
|
def test_anonymous_is_sent_to_login(client, path):
|
|
response = client.get(path)
|
|
assert response.status_code == 302
|
|
assert "/comunidad/login" in response.headers["Location"]
|
|
|
|
|
|
def _stub_gitea(monkeypatch, login):
|
|
# exchange_code returns the whole token response now, because the editor
|
|
# needs the refresh token to keep working past Gitea's one-hour expiry.
|
|
monkeypatch.setattr(gitea, "exchange_code", lambda code, uri: {
|
|
"access_token": "token", "refresh_token": "refresh", "expires_in": 3600,
|
|
})
|
|
monkeypatch.setattr(gitea, "fetch_user", lambda token: {
|
|
"login": login, "full_name": login.title(), "email": f"{login}@example.com",
|
|
})
|
|
|
|
|
|
def _callback(client, monkeypatch, login):
|
|
_stub_gitea(monkeypatch, login)
|
|
with client.session_transaction() as session:
|
|
session["oauth_state"] = "state123"
|
|
return client.get("/comunidad/auth/callback?code=abc&state=state123")
|
|
|
|
|
|
def test_a_gitea_account_is_not_a_membership(client, monkeypatch):
|
|
"""The single most important rule in the app: Gitea says who you are, the
|
|
members table says whether you belong. The translations bot has a perfectly
|
|
valid Gitea account and must not get in."""
|
|
response = _callback(client, monkeypatch, "vienalatina-translations")
|
|
assert response.status_code == 302
|
|
with client.session_transaction() as session:
|
|
assert "member_id" not in session
|
|
|
|
|
|
def test_member_signs_in(client, monkeypatch, make_member):
|
|
make_member("maria")
|
|
response = _callback(client, monkeypatch, "maria")
|
|
assert response.status_code == 302
|
|
with client.session_transaction() as session:
|
|
assert "member_id" in session
|
|
|
|
|
|
def test_suspended_member_cannot_sign_in(client, monkeypatch, make_member):
|
|
make_member("expulsada", active=0)
|
|
_callback(client, monkeypatch, "expulsada")
|
|
with client.session_transaction() as session:
|
|
assert "member_id" not in session
|
|
|
|
|
|
def test_suspension_takes_effect_on_the_next_request(client, db, make_member, sign_in):
|
|
"""The role is read per request, not cached in the cookie, so revoking
|
|
access does not wait for a session to expire."""
|
|
member_id = make_member("temporal")
|
|
sign_in(member_id)
|
|
assert client.get("/comunidad/").status_code == 200
|
|
|
|
db.execute("UPDATE members SET active = 0 WHERE id = ?", (member_id,))
|
|
assert client.get("/comunidad/").status_code == 302
|
|
|
|
|
|
def test_callback_rejects_a_mismatched_state(client, monkeypatch, make_member):
|
|
make_member("maria")
|
|
_stub_gitea(monkeypatch, "maria")
|
|
with client.session_transaction() as session:
|
|
session["oauth_state"] = "the-real-state"
|
|
client.get("/comunidad/auth/callback?code=abc&state=attacker-state")
|
|
with client.session_transaction() as session:
|
|
assert "member_id" not in session
|
|
|
|
|
|
def test_post_without_csrf_is_refused(client, make_member, sign_in):
|
|
sign_in(make_member("maria"))
|
|
response = client.post("/comunidad/nuevo", data={"title": "Hola", "body": "Texto"})
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_login_redirect_cannot_be_pointed_offsite(client, monkeypatch, make_member):
|
|
make_member("maria")
|
|
_stub_gitea(monkeypatch, "maria")
|
|
with client.session_transaction() as session:
|
|
session["oauth_state"] = "state123"
|
|
response = client.get(
|
|
"/comunidad/auth/callback?code=abc&state=state123&next=https://evil.example.com/"
|
|
)
|
|
assert "evil.example.com" not in response.headers["Location"]
|
|
|
|
|
|
def test_responses_say_do_not_index(client):
|
|
response = client.get("/comunidad/login")
|
|
assert response.headers["X-Robots-Tag"] == "noindex, nofollow"
|
|
|
|
|
|
def test_logout_says_the_gitea_session_is_still_open(client, db, make_member, sign_in, post):
|
|
"""Redirecting to the login page would hide the problem: one click on
|
|
"Entrar con Gitea" signs you straight back in, because Gitea's session and
|
|
its record of the authorisation both survive."""
|
|
member_id = make_member("maria")
|
|
db.execute(
|
|
"INSERT INTO gitea_tokens (member_id, access_token) VALUES (?, 'tok')",
|
|
(member_id,),
|
|
)
|
|
sign_in(member_id)
|
|
|
|
response = post("/comunidad/logout")
|
|
page = response.get_data(as_text=True)
|
|
|
|
assert response.status_code == 200
|
|
assert "sigue conectado" in page # the warning, not a redirect
|
|
# This line used to assert `/user/logout` was in the page, which made the
|
|
# suite enforce the bug rather than catch it: that route is POST-only, so
|
|
# the link it was guarding answered 404 and closed nothing. What the page
|
|
# owes the member is the instruction and a way to get there.
|
|
assert "/user/logout" not in page
|
|
assert "Cerrar sesión" in page
|
|
|
|
with client.session_transaction() as session:
|
|
assert "member_id" not in session
|
|
assert db.execute("SELECT 1 FROM gitea_tokens WHERE member_id = ?",
|
|
(member_id,)).fetchone() is None
|
|
|
|
|
|
def test_logout_leaves_nothing_the_server_can_act_with(client, db, make_member, sign_in, post):
|
|
"""The token is what lets this server commit as the member. Clearing the
|
|
cookie without dropping it would end the browser's access but not ours."""
|
|
member_id = make_member("maria")
|
|
db.execute(
|
|
"INSERT INTO gitea_tokens (member_id, access_token) VALUES (?, 'tok')",
|
|
(member_id,),
|
|
)
|
|
sign_in(member_id)
|
|
post("/comunidad/logout")
|
|
|
|
assert db.execute("SELECT COUNT(*) AS n FROM gitea_tokens").fetchone()["n"] == 0
|