Two silent failures sitting between Phase A and a working invitation. The compose file never passed MAIL_* through. Compose does not give a container the contents of .env — it only substitutes into the compose file — so the settings could be filled in correctly and read by nobody, with the app reporting mail as unconfigured and the values sitting right there on disk. test_deployment.py now fails the build whenever .env.example and docker-compose.yml drift apart, which is how this happened and how it would happen again. The app's code is baked into the image (COPY apps /srv/apps), so a pull followed by --force-recreate runs the old code and says nothing. Added scripts/deploy-board.sh: rebuild, sync the compose file, restart, print the log, and name any setting present in .env.example and missing from the live .env. It never touches .env itself. Also: "no mail server configured" is now told apart from "the mail server refused". They want different things done about them, and the first one sent an admin looking for an SMTP error that never existed. The new-member form says so before it is filled in rather than after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""Invitations, password resets, and the rules that keep a link from being a
|
|
permanent key to somebody's account.
|
|
|
|
A token here is a bearer credential: whoever holds it sets the password. So
|
|
most of these tests are about the ways a token must *stop* working, and about
|
|
what the pages give away to somebody who is only guessing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from apps.board import gitea, invites, mail
|
|
|
|
|
|
@pytest.fixture
|
|
def outbox(monkeypatch):
|
|
"""Mail captured rather than sent. No SMTP anywhere in the suite."""
|
|
sent = []
|
|
monkeypatch.setattr(mail, "send", lambda to, subject, body: sent.append(
|
|
{"to": to, "subject": subject, "body": body}))
|
|
return sent
|
|
|
|
|
|
@pytest.fixture
|
|
def passwords(monkeypatch):
|
|
"""Gitea's password API stubbed; the calls are what matters."""
|
|
changed = []
|
|
monkeypatch.setattr(gitea, "admin_set_password",
|
|
lambda login, password: changed.append((login, password)))
|
|
return changed
|
|
|
|
|
|
def link_in(message: str) -> str:
|
|
for word in message.split():
|
|
if "/comunidad/invitacion/" in word:
|
|
return word
|
|
raise AssertionError("no invite link in the message")
|
|
|
|
|
|
def token_in(message: str) -> str:
|
|
return link_in(message).rsplit("/", 1)[1]
|
|
|
|
|
|
# --- the tokens themselves ------------------------------------------------
|
|
|
|
def test_the_database_never_holds_the_token_itself(app, db, make_member):
|
|
"""A leaked backup should be a list of useless hashes, not live keys."""
|
|
with app.test_request_context():
|
|
member_id = make_member("maria")
|
|
token = invites.issue(member_id, "invite")
|
|
|
|
stored = db.execute("SELECT token_hash FROM invites").fetchone()["token_hash"]
|
|
assert token not in stored
|
|
assert len(stored) == 64 # sha256 hex, not the 43-char token
|
|
|
|
|
|
def test_a_token_works_once(app, db, make_member):
|
|
with app.test_request_context():
|
|
member_id = make_member("maria")
|
|
token = invites.issue(member_id, "invite")
|
|
|
|
found = invites.lookup(token)
|
|
assert found["id"] == member_id
|
|
|
|
invites.consume(found["invite_id"])
|
|
assert invites.lookup(token) is None
|
|
|
|
|
|
def test_an_expired_token_is_refused(app, db, make_member):
|
|
with app.test_request_context():
|
|
member_id = make_member("maria")
|
|
token = invites.issue(member_id, "reset")
|
|
db.execute(
|
|
"UPDATE invites SET expires_at = ? WHERE member_id = ?",
|
|
((datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat(), member_id),
|
|
)
|
|
assert invites.lookup(token) is None
|
|
|
|
|
|
def test_issuing_a_new_token_kills_the_old_one(app, db, make_member):
|
|
"""Asking for a second reset should not leave the first one live in an
|
|
inbox somebody else can read."""
|
|
with app.test_request_context():
|
|
member_id = make_member("maria")
|
|
first = invites.issue(member_id, "reset")
|
|
second = invites.issue(member_id, "reset")
|
|
|
|
assert invites.lookup(first) is None
|
|
assert invites.lookup(second) is not None
|
|
|
|
|
|
def test_a_suspended_member_cannot_use_their_link(app, db, make_member):
|
|
with app.test_request_context():
|
|
member_id = make_member("expulsada")
|
|
token = invites.issue(member_id, "invite")
|
|
db.execute("UPDATE members SET active = 0 WHERE id = ?", (member_id,))
|
|
assert invites.lookup(token) is None
|
|
|
|
|
|
def test_a_made_up_token_is_refused(app):
|
|
with app.test_request_context():
|
|
assert invites.lookup("not-a-real-token") is None
|
|
assert invites.lookup("") is None
|
|
|
|
|
|
# --- setting the password -------------------------------------------------
|
|
|
|
def test_a_member_sets_their_own_password(app, client, db, post, make_member, passwords):
|
|
with app.test_request_context():
|
|
member_id = make_member("maria")
|
|
token = invites.issue(member_id, "invite")
|
|
|
|
response = post(f"/comunidad/invitacion/{token}",
|
|
{"password": "una-contrasena-larga", "confirm": "una-contrasena-larga"})
|
|
|
|
assert response.status_code == 302
|
|
assert passwords == [("maria", "una-contrasena-larga")]
|
|
assert db.execute("SELECT used_at FROM invites").fetchone()["used_at"] is not None
|
|
|
|
|
|
def test_a_short_password_is_refused_and_the_link_survives(
|
|
app, client, db, post, make_member, passwords):
|
|
"""Rejecting the password must not spend the token, or a typo locks the
|
|
member out of an account they have never reached."""
|
|
with app.test_request_context():
|
|
member_id = make_member("maria")
|
|
token = invites.issue(member_id, "invite")
|
|
|
|
response = post(f"/comunidad/invitacion/{token}",
|
|
{"password": "corta", "confirm": "corta"})
|
|
|
|
assert response.status_code == 400
|
|
assert passwords == []
|
|
assert db.execute("SELECT used_at FROM invites").fetchone()["used_at"] is None
|
|
assert client.get(f"/comunidad/invitacion/{token}").status_code == 200
|
|
|
|
|
|
def test_mismatched_passwords_are_refused(app, post, make_member, passwords):
|
|
with app.test_request_context():
|
|
token = invites.issue(make_member("maria"), "invite")
|
|
|
|
response = post(f"/comunidad/invitacion/{token}",
|
|
{"password": "una-contrasena-larga", "confirm": "otra-cosa-larga"})
|
|
assert response.status_code == 400
|
|
assert passwords == []
|
|
|
|
|
|
def test_a_rejection_from_gitea_leaves_the_link_usable(
|
|
app, db, post, make_member, monkeypatch):
|
|
def refuse(login, password):
|
|
raise gitea.GiteaError("Gitea rechazó esa contraseña.")
|
|
monkeypatch.setattr(gitea, "admin_set_password", refuse)
|
|
|
|
with app.test_request_context():
|
|
token = invites.issue(make_member("maria"), "invite")
|
|
|
|
response = post(f"/comunidad/invitacion/{token}",
|
|
{"password": "una-contrasena-larga", "confirm": "una-contrasena-larga"})
|
|
assert response.status_code == 400
|
|
assert db.execute("SELECT used_at FROM invites").fetchone()["used_at"] is None
|
|
|
|
|
|
def test_a_dead_link_says_nothing_about_the_account(client):
|
|
body = client.get("/comunidad/invitacion/inventado").get_data(as_text=True)
|
|
assert "ya no sirve" in body
|
|
# Not "expired", not "already used", not "unknown" — those distinctions tell
|
|
# the holder of a stale link something about the account behind it.
|
|
assert "caducado" not in body
|
|
|
|
|
|
# --- recovery -------------------------------------------------------------
|
|
|
|
def test_recovery_emails_a_member(app, client, post, db, make_member, outbox):
|
|
make_member("maria")
|
|
response = post("/comunidad/recuperar", {"email": "maria@example.com"})
|
|
|
|
assert response.status_code == 200
|
|
assert len(outbox) == 1
|
|
assert outbox[0]["to"] == "maria@example.com"
|
|
with app.test_request_context():
|
|
assert invites.lookup(token_in(outbox[0]["body"])) is not None
|
|
|
|
|
|
def test_recovery_answers_the_same_for_an_unknown_address(client, post, outbox):
|
|
"""Otherwise the form is a way to find out who is a member, one address at
|
|
a time."""
|
|
known = post("/comunidad/recuperar", {"email": "maria@example.com"})
|
|
unknown = post("/comunidad/recuperar", {"email": "nadie@example.com"})
|
|
|
|
assert known.status_code == unknown.status_code == 200
|
|
assert known.get_data() == unknown.get_data()
|
|
assert outbox == []
|
|
|
|
|
|
def test_recovery_stops_after_a_few_tries(client, post, make_member, outbox):
|
|
"""A reset form with no limit is a way to mail-bomb somebody using your
|
|
server's reputation."""
|
|
make_member("maria")
|
|
for _ in range(6):
|
|
post("/comunidad/recuperar", {"email": "maria@example.com"})
|
|
|
|
assert len(outbox) == invites.RESET_LIMIT
|
|
|
|
|
|
def test_a_suspended_member_gets_no_reset(client, post, make_member, outbox):
|
|
make_member("expulsada", active=0)
|
|
post("/comunidad/recuperar", {"email": "expulsada@example.com"})
|
|
assert outbox == []
|
|
|
|
|
|
# --- inviting from the members screen -------------------------------------
|
|
|
|
def test_creating_a_member_emails_them_instead_of_showing_a_password(
|
|
app, client, post, owner_id, sign_in, outbox, monkeypatch):
|
|
monkeypatch.setattr(gitea, "admin_create_user",
|
|
lambda login, email, name, password: None)
|
|
sign_in(owner_id)
|
|
|
|
response = post("/comunidad/miembros/nuevo", {
|
|
"login": "maria", "display_name": "María", "email": "m@example.com",
|
|
"role": "user", "create_account": "on",
|
|
})
|
|
page = response.get_data(as_text=True)
|
|
|
|
assert len(outbox) == 1
|
|
assert outbox[0]["to"] == "m@example.com"
|
|
assert "m@example.com" in page
|
|
# The admin never sees a password, so there is none to pass on or mislay.
|
|
assert "/comunidad/invitacion/" not in page
|
|
|
|
|
|
def test_when_mail_fails_the_admin_is_given_the_link(
|
|
app, client, post, owner_id, sign_in, monkeypatch):
|
|
"""Otherwise the account exists and the member simply never gets in."""
|
|
monkeypatch.setattr(gitea, "admin_create_user",
|
|
lambda login, email, name, password: None)
|
|
|
|
def explode(to, subject, body):
|
|
raise mail.MailFailed("connection refused")
|
|
monkeypatch.setattr(mail, "send", explode)
|
|
sign_in(owner_id)
|
|
|
|
response = post("/comunidad/miembros/nuevo", {
|
|
"login": "maria", "display_name": "María", "email": "m@example.com",
|
|
"role": "user", "create_account": "on",
|
|
})
|
|
page = response.get_data(as_text=True)
|
|
|
|
assert "No se pudo enviar el correo" in page
|
|
assert "/comunidad/invitacion/" in page
|
|
|
|
|
|
def test_linking_an_existing_account_sends_nothing(
|
|
app, client, post, owner_id, sign_in, outbox):
|
|
"""They already have a password; an unexpected invitation would be noise."""
|
|
sign_in(owner_id)
|
|
post("/comunidad/miembros/nuevo", {
|
|
"login": "maria", "display_name": "María", "email": "m@example.com",
|
|
"role": "user", "create_account": "",
|
|
})
|
|
assert outbox == []
|
|
|
|
|
|
# --- when the server cannot send at all -----------------------------------
|
|
|
|
def test_an_unconfigured_server_does_not_blame_the_mail_server(
|
|
app, client, post, owner_id, sign_in, monkeypatch):
|
|
"""No MAIL_HOST is not a failure, and saying "no se pudo enviar" sends the
|
|
admin hunting for an SMTP error that was never produced."""
|
|
monkeypatch.setattr(gitea, "admin_create_user",
|
|
lambda login, email, name, password: None)
|
|
app.config["MAIL_HOST"] = ""
|
|
sign_in(owner_id)
|
|
|
|
page = post("/comunidad/miembros/nuevo", {
|
|
"login": "maria", "display_name": "María", "email": "m@example.com",
|
|
"role": "user", "create_account": "on",
|
|
}).get_data(as_text=True)
|
|
|
|
assert "todavía no envía correo" in page
|
|
assert "No se pudo enviar el correo" not in page
|
|
assert "/comunidad/invitacion/" in page
|
|
|
|
|
|
def test_the_form_warns_before_it_is_filled_in(app, client, owner_id, sign_in):
|
|
app.config["MAIL_HOST"] = ""
|
|
sign_in(owner_id)
|
|
assert "no envía correo" in client.get(
|
|
"/comunidad/miembros/nuevo").get_data(as_text=True)
|
|
|
|
app.config["MAIL_HOST"] = "smtp.example.com"
|
|
assert "no envía correo" not in client.get(
|
|
"/comunidad/miembros/nuevo").get_data(as_text=True)
|