Two halves of the same complaint: the journey into the members area does not feel like one platform. Members sign in to /comunidad/ through Gitea, so Gitea's sign-in form and authorize dialog are part of everyone's journey, not just of anyone who opens a repository. That is what the earlier "is Gitea only for admins?" question got wrong. Unthemed they are two dark screens in the middle of a cream site. Unlike Decap, Gitea supports being themed, so this needs no forking and nothing a release can silently undo. The repository holds only the colour overrides; scripts/gitea-theme.sh concatenates them onto the light theme it reads out of the running container. Keeping somebody else's stylesheet in here would go stale and turn every Gitea upgrade into a merge — the script is re-run instead, which is one line in the upgrade notes. Getting a variable name wrong leaves a corner grey rather than breaking a page, which is the failure mode worth having when the names belong to someone else's project. The logo is the site's wordmark, as live text in the same font stack rather than traced to paths: the site loads no webfont at all, deliberately, so visitors already see it in whatever sans-serif their machine substitutes. Paths would render a Montserrat most people never see on the site itself. Salir was the sharper problem. It cleared this session and the stored token, and then said "Sesión cerrada" — while the Gitea session in the same browser stayed open and Gitea still remembered the authorisation, so one click signed the next person straight back in with no password. On a laptop shared around an association, that button was lying. It cannot be fixed from here. Gitea's logout has been POST-only since 1.11.2, so a link cannot trigger it and a cross-site POST needs a CSRF token this app does not have; prompt=login is undocumented in every released version of Gitea's OAuth2 provider, and a security control should not rest on that. So the logout page now says exactly what is closed and what is not, and links to the one place that finishes it. Being honest about a limit beats a reassuring message that is false. Verified: 101 checks, two of them new — logout warns rather than redirecting, and leaves no token the server could still act with. The theme itself is visual and has to be looked at; docs/server-setup.md §12 says where. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
147 lines
5.3 KiB
Python
147 lines
5.3 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
|
|
assert "/user/logout" 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
|