Page at /email asks for the new address and the current password (shared failure counter with the password page, five requests per hour) and mails a one-hour confirmation link to the new address. Confirming writes the address and mails the old address a 24-hour undo link; undoing restores it, ends every session of the user and notifies the Admin (ADMIN_NOTIFY_EMAIL). Link pages show on GET and apply on POST; tokens are stored only as hashes, single use, and are blanked from every log line. New table owned by profile, German text, tests; review findings logged in the plan. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FocPJ75XPma7TcuYEMy8zm
694 lines
27 KiB
Python
694 lines
27 KiB
Python
"""E-mail change: request, confirmation link, undo link, limits, sessions, audit and logs."""
|
||
|
||
import logging
|
||
import re
|
||
from datetime import timedelta
|
||
|
||
import pytest
|
||
from django.core.management import call_command
|
||
from django.test import Client
|
||
from django.utils import timezone
|
||
|
||
from portal.accounts.models import ThrottleCounter, UserSession
|
||
from portal.adapters.directory import DirectoryUnavailable, ValidationFailed
|
||
from portal.audit.models import AuditEntry
|
||
from portal.mailer.models import OutboxMail
|
||
from portal.profile import services
|
||
from portal.profile.models import PendingEmailChange
|
||
from portal.tests.helpers import sign_in
|
||
|
||
pytestmark = pytest.mark.django_db(transaction=True)
|
||
|
||
CURRENT = "Correct-Horse-9"
|
||
WRONG = "Not-The-Password-1"
|
||
OLD = "alice@example.test"
|
||
NEW = "alice.new@example.test"
|
||
ADMIN = "admin@example.test"
|
||
LINK = re.compile(r"https://portal\.example\.test(/email/(?:confirm|undo)/[\w-]+)")
|
||
DEAD = "This link is not valid any more."
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def admin_address(settings):
|
||
settings.ADMIN_NOTIFY_EMAIL = ADMIN
|
||
|
||
|
||
@pytest.fixture
|
||
def people(adapters):
|
||
d = adapters.directory
|
||
d.add_user("alice", password=CURRENT, display_name="Alice Anders", mail=OLD)
|
||
d.add_user("bob", display_name="Bob Brown", mail="bob@example.test")
|
||
d.add_user("nomail", password=CURRENT, display_name="No Mail")
|
||
return d
|
||
|
||
|
||
class Spy:
|
||
def __init__(self, directory):
|
||
self.calls = []
|
||
for name in ("password_policy", "verify_password", "find_user", "update_attributes"):
|
||
self._wrap(directory, name)
|
||
|
||
def _wrap(self, directory, name):
|
||
original = getattr(directory, name)
|
||
|
||
def wrapper(*args, **kwargs):
|
||
self.calls.append(name)
|
||
return original(*args, **kwargs)
|
||
|
||
setattr(directory, name, wrapper)
|
||
|
||
|
||
def signed_in(adapters, name="alice"):
|
||
client, _ = sign_in(adapters, name)
|
||
return client
|
||
|
||
|
||
def ask(client, new=NEW, password=CURRENT):
|
||
return client.post("/email", {"new_email": new, "current_password": password})
|
||
|
||
|
||
def mails(to=None):
|
||
rows = OutboxMail.objects.all()
|
||
return list(rows.filter(recipient=to) if to else rows)
|
||
|
||
|
||
def link_in(mail):
|
||
return LINK.search(mail.body_text).group(1)
|
||
|
||
|
||
def request_link(adapters, name="alice", new=NEW):
|
||
"""Sign in, ask for a change and return the confirmation path."""
|
||
client = signed_in(adapters, name)
|
||
assert ask(client, new).status_code == 302
|
||
return link_in(mails(new)[-1])
|
||
|
||
|
||
def confirmed_undo_path(adapters):
|
||
path = request_link(adapters)
|
||
Client().post(path)
|
||
return link_in(mails(OLD)[-1])
|
||
|
||
|
||
def actions():
|
||
names = AuditEntry.objects.values_list("action", flat=True)
|
||
return [name for name in names if name.startswith("email.")]
|
||
|
||
|
||
def mail_of(adapters, name="alice"):
|
||
return adapters.directory.find_user(name).mail
|
||
|
||
|
||
# -- the page -----------------------------------------
|
||
|
||
|
||
def test_the_page_shows_the_current_address_and_is_reached_from_the_profile(people, adapters):
|
||
client = signed_in(adapters)
|
||
profile = client.get("/profile").content.decode()
|
||
assert 'href="/email"' in profile and "Change e-mail address" in profile
|
||
html = client.get("/email").content.decode()
|
||
assert OLD in html and 'name="new_email"' in html and 'name="current_password"' in html
|
||
assert 'autocomplete="current-password"' in html and "<form" in html
|
||
|
||
|
||
def test_the_page_is_unavailable_when_the_directory_is_down(people, adapters):
|
||
client = signed_in(adapters)
|
||
adapters.directory.unavailable = True
|
||
assert client.get("/email").status_code == 503
|
||
|
||
|
||
def test_the_page_and_the_mail_speak_german(people, adapters):
|
||
client = signed_in(adapters)
|
||
client.cookies["pm_lang"] = "de"
|
||
assert "E-Mail-Adresse ändern" in client.get("/email").content.decode()
|
||
assert ask(client).status_code == 302
|
||
mail = mails(NEW)[0]
|
||
assert mail.subject == "Bitte bestätigen Sie Ihre neue E-Mail-Adresse"
|
||
assert "Guten Tag Alice Anders," in mail.body_text and "eine Stunde" in mail.body_text
|
||
|
||
|
||
# -- the request -----------------------------------------
|
||
|
||
|
||
def test_a_request_mails_a_link_to_the_new_address_and_leaves_the_directory_alone(people, adapters):
|
||
client = signed_in(adapters)
|
||
response = ask(client)
|
||
assert response.status_code == 302 and response["Location"] == "/email"
|
||
assert mail_of(adapters) == OLD
|
||
[mail] = mails()
|
||
assert mail.recipient == NEW and mail.template == "email_change_confirm"
|
||
assert LINK.search(mail.body_text) and "/email/confirm/" in mail.body_text
|
||
assert "password" not in mail.body_text.lower().replace("this e-mail never", "")
|
||
sent = client.get("/email").content.decode()
|
||
assert "We sent a link" in sent and 'name="new_email"' not in sent
|
||
assert 'name="new_email"' in client.get("/email").content.decode() # shown once
|
||
assert actions() == ["email.change_requested"]
|
||
|
||
|
||
def test_only_the_hash_of_a_token_is_stored(people, adapters):
|
||
path = request_link(adapters)
|
||
token = path.rsplit("/", 1)[1]
|
||
assert len(token) == 43 # secrets.token_urlsafe(32)
|
||
row = PendingEmailChange.objects.get()
|
||
assert row.token_hash == services.hash_token(token) and len(row.token_hash) == 64
|
||
assert token not in " ".join(str(v) for v in row.__dict__.values())
|
||
assert row.kind == "confirm" and row.user_key == "alice" and row.sam == "alice"
|
||
assert row.expires_at - row.created_at == timedelta(hours=1)
|
||
assert row.old_address == OLD and row.new_address == NEW and row.used_at is None
|
||
|
||
|
||
def test_the_order_is_form_then_throttle_then_password_then_the_write(people, adapters):
|
||
spy = Spy(adapters.directory)
|
||
client = signed_in(adapters)
|
||
spy.calls.clear()
|
||
assert ask(client, new="nonsense").status_code == 400
|
||
assert spy.calls == [] # the form is checked before any directory call
|
||
ThrottleCounter.objects.create(
|
||
endpoint="password-change",
|
||
subject="user:alice",
|
||
count=4,
|
||
blocked_until=timezone.now() + timedelta(minutes=5),
|
||
)
|
||
assert ask(client).status_code == 429
|
||
assert spy.calls == [] and mails() == [] and PendingEmailChange.objects.count() == 0
|
||
assert "email.throttled" in actions()
|
||
|
||
|
||
def test_a_wrong_password_is_refused_counted_and_audited(people, adapters):
|
||
client = signed_in(adapters)
|
||
response = ask(client, password=WRONG)
|
||
assert response.status_code == 400
|
||
assert "The current password is not correct." in response.content.decode()
|
||
assert mails() == [] and PendingEmailChange.objects.count() == 0
|
||
assert ThrottleCounter.objects.get(endpoint="password-change", subject="user:alice").count == 1
|
||
assert AuditEntry.objects.get(action="email.change_failed").detail == {
|
||
"reason": "wrong_current"
|
||
}
|
||
assert mail_of(adapters) == OLD
|
||
|
||
|
||
def test_both_pages_share_one_counter_below_the_directory_lockout(people, adapters):
|
||
client = signed_in(adapters)
|
||
# The fake directory locks at 5, so the portal allows 4 wrong passwords in all.
|
||
for _ in range(2):
|
||
client.post(
|
||
"/password",
|
||
{
|
||
"current_password": WRONG,
|
||
"new_password": "Xx1-new-pass",
|
||
"confirm_password": "Xx1-new-pass",
|
||
},
|
||
)
|
||
assert ask(client, password=WRONG).status_code == 400
|
||
assert ask(client, password=WRONG).status_code == 400
|
||
spy = Spy(adapters.directory)
|
||
response = ask(client) # even the right password: the limit is reached
|
||
assert response.status_code == 429
|
||
assert "Too many attempts. Please wait a few minutes." in response.content.decode()
|
||
assert spy.calls == [] and mails() == []
|
||
assert ThrottleCounter.objects.get(endpoint="password-change", subject="user:alice").count == 4
|
||
# and the password page is paused as well
|
||
blocked = client.post(
|
||
"/password",
|
||
{
|
||
"current_password": CURRENT,
|
||
"new_password": "Xx1-new-pass",
|
||
"confirm_password": "Xx1-new-pass",
|
||
},
|
||
)
|
||
assert blocked.status_code == 429
|
||
|
||
|
||
def test_five_requests_per_hour_are_allowed_and_the_sixth_is_not(people, adapters):
|
||
client = signed_in(adapters)
|
||
for number in range(5):
|
||
assert ask(client, new=f"new{number}@example.test").status_code == 302
|
||
spy = Spy(adapters.directory)
|
||
assert ask(client, new="sixth@example.test").status_code == 429
|
||
assert spy.calls == [] and len(mails()) == 5
|
||
assert actions().count("email.throttled") == 1
|
||
ThrottleCounter.objects.filter(endpoint="email-change-request").update(
|
||
blocked_until=timezone.now() - timedelta(seconds=1)
|
||
)
|
||
assert ask(client, new="after@example.test").status_code == 302 # recovers after the pause
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value",
|
||
["", " ", "not-an-address", "a b@example.test", "x" * 250 + "@example.test", OLD.upper()],
|
||
)
|
||
def test_a_bad_new_address_is_refused_and_not_counted(people, adapters, value):
|
||
client = signed_in(adapters)
|
||
response = ask(client, new=value)
|
||
assert response.status_code == 400 and "No link was sent." in response.content.decode()
|
||
assert mails() == [] and PendingEmailChange.objects.count() == 0
|
||
assert not ThrottleCounter.objects.exists()
|
||
|
||
|
||
def test_the_same_address_in_another_letter_case_is_named(people, adapters):
|
||
response = ask(signed_in(adapters), new="Alice@Example.Test")
|
||
assert "This is already your e-mail address." in response.content.decode()
|
||
|
||
|
||
def test_a_newer_request_replaces_the_pending_one(people, adapters):
|
||
client = signed_in(adapters)
|
||
ask(client, new="first@example.test")
|
||
ask(client, new="second@example.test")
|
||
first = link_in(mails("first@example.test")[0])
|
||
second = link_in(mails("second@example.test")[0])
|
||
other = Client()
|
||
assert other.get(first).status_code == 410 and other.post(first).status_code == 410
|
||
assert other.get(second).status_code == 200
|
||
assert other.post(second).status_code == 200
|
||
assert mail_of(adapters) == "second@example.test"
|
||
|
||
|
||
def test_the_request_works_when_the_user_has_no_address_yet(people, adapters):
|
||
client = signed_in(adapters, "nomail")
|
||
assert (
|
||
"–" in client.get("/email").content.decode()
|
||
or "–" in client.get("/email").content.decode()
|
||
)
|
||
assert ask(client).status_code == 302
|
||
assert len(mails(NEW)) == 1
|
||
|
||
|
||
def test_a_request_is_unavailable_when_the_directory_is_down(people, adapters):
|
||
client = signed_in(adapters)
|
||
adapters.directory.unavailable = True
|
||
assert ask(client).status_code == 503
|
||
assert mails() == [] and PendingEmailChange.objects.count() == 0
|
||
assert AuditEntry.objects.get(action="email.change_failed").detail["reason"] == "unavailable"
|
||
|
||
|
||
# -- the confirmation link -----------------------------------------
|
||
|
||
|
||
def test_opening_the_confirmation_link_changes_and_consumes_nothing(people, adapters):
|
||
path = request_link(adapters)
|
||
before = list(AuditEntry.objects.values_list("id", flat=True))
|
||
visitor = Client() # not signed in
|
||
for _ in range(3):
|
||
response = visitor.get(path)
|
||
assert response.status_code == 200
|
||
html = response.content.decode()
|
||
assert OLD in html and NEW in html and "<form" in html and 'method="post"' in html
|
||
assert "csrfmiddlewaretoken" in html
|
||
assert (
|
||
response["Cache-Control"].startswith("max-age=0")
|
||
or "no-cache" in response["Cache-Control"]
|
||
)
|
||
assert visitor.head(path).status_code in (200, 405)
|
||
assert mail_of(adapters) == OLD
|
||
assert PendingEmailChange.objects.get().used_at is None
|
||
assert len(mails()) == 1 and list(AuditEntry.objects.values_list("id", flat=True)) == before
|
||
|
||
|
||
def test_confirming_writes_the_address_and_mails_the_old_one_an_undo_link(people, adapters):
|
||
path = request_link(adapters)
|
||
visitor = Client()
|
||
response = visitor.post(path)
|
||
assert (
|
||
response.status_code == 200
|
||
and "Your e-mail address was changed." in response.content.decode()
|
||
)
|
||
assert "A notice with a way to undo" in response.content.decode()
|
||
assert mail_of(adapters) == NEW
|
||
confirm = PendingEmailChange.objects.get(kind="confirm")
|
||
assert confirm.used_at is not None
|
||
undo = PendingEmailChange.objects.get(kind="undo")
|
||
assert undo.old_address == OLD and undo.new_address == NEW
|
||
assert undo.expires_at - undo.created_at == timedelta(hours=24)
|
||
[notice] = mails(OLD)
|
||
assert notice.template == "email_changed_notice"
|
||
token = link_in(notice).rsplit("/", 1)[1]
|
||
assert undo.token_hash == services.hash_token(token) and token != undo.token_hash
|
||
assert NEW in notice.body_text and "24" in notice.body_text
|
||
entry = AuditEntry.objects.get(action="email.changed")
|
||
assert entry.actor == "alice" and entry.detail == {"notice": "queued"}
|
||
|
||
|
||
def test_a_confirmation_works_once(people, adapters):
|
||
path = request_link(adapters)
|
||
visitor = Client()
|
||
assert visitor.post(path).status_code == 200
|
||
assert visitor.post(path).status_code == 410
|
||
assert visitor.get(path).status_code == 410
|
||
assert len(mails(OLD)) == 1
|
||
|
||
|
||
def test_dead_links_all_look_alike(people, adapters):
|
||
unknown = Client().get("/email/confirm/" + "a" * 43)
|
||
assert unknown.status_code == 410 and DEAD in unknown.content.decode()
|
||
|
||
expired = request_link(adapters)
|
||
PendingEmailChange.objects.update(expires_at=timezone.now() - timedelta(seconds=1))
|
||
used = confirmed_undo_path(adapters) # a fresh pair of rows
|
||
PendingEmailChange.objects.filter(kind="undo").update(used_at=timezone.now())
|
||
|
||
pages = {
|
||
Client().get(expired).content,
|
||
Client().get(used).content,
|
||
Client().post(expired).content,
|
||
Client().post(used).content,
|
||
unknown.content,
|
||
}
|
||
assert len(pages) == 1 # the page does not say which case it is
|
||
assert Client().post(expired).status_code == 410
|
||
|
||
|
||
def test_a_confirmation_fails_cleanly_when_the_directory_is_down(people, adapters):
|
||
path = request_link(adapters)
|
||
adapters.directory.unavailable = True
|
||
response = Client().post(path)
|
||
assert response.status_code == 503
|
||
adapters.directory.unavailable = False
|
||
assert PendingEmailChange.objects.get().used_at is None
|
||
assert mails(OLD) == [] and mail_of(adapters) == OLD
|
||
assert AuditEntry.objects.get(action="email.change_failed").detail == {
|
||
"reason": "unavailable",
|
||
"stage": "confirm",
|
||
}
|
||
assert Client().post(path).status_code == 200 # and it works once the directory is back
|
||
|
||
|
||
def test_a_refused_write_rolls_everything_back(people, adapters, monkeypatch):
|
||
path = request_link(adapters)
|
||
|
||
def refuse(*args, **kwargs):
|
||
raise ValidationFailed("no")
|
||
|
||
monkeypatch.setattr(adapters.directory, "update_attributes", refuse)
|
||
assert Client().post(path).status_code == 503
|
||
assert PendingEmailChange.objects.get().used_at is None and mails(OLD) == []
|
||
assert "email.changed" not in actions()
|
||
|
||
|
||
def test_a_user_without_an_address_can_change_it_without_a_notice(people, adapters):
|
||
client = signed_in(adapters, "nomail")
|
||
ask(client)
|
||
path = link_in(mails(NEW)[0])
|
||
response = Client().post(path)
|
||
assert response.status_code == 200 and "A notice with a way" not in response.content.decode()
|
||
assert Client().get(path).status_code == 410
|
||
assert mail_of(adapters, "nomail") == NEW
|
||
assert PendingEmailChange.objects.filter(kind="undo").count() == 0
|
||
assert [m.recipient for m in mails()] == [NEW]
|
||
assert AuditEntry.objects.get(action="email.changed").detail == {"notice": "skipped"}
|
||
page = Client().get(link_in(mails(NEW)[0])) # nothing to show any more
|
||
assert page.status_code == 410
|
||
|
||
|
||
def test_the_confirmation_page_does_not_mention_a_notice_without_an_old_address(people, adapters):
|
||
client = signed_in(adapters, "nomail")
|
||
ask(client)
|
||
html = Client().get(link_in(mails(NEW)[0])).content.decode()
|
||
assert "A notice with a way" not in html and NEW in html
|
||
|
||
|
||
# -- the undo link -----------------------------------------
|
||
|
||
|
||
def test_opening_the_undo_link_changes_and_consumes_nothing(people, adapters):
|
||
path = confirmed_undo_path(adapters)
|
||
count = AuditEntry.objects.count()
|
||
for _ in range(2):
|
||
response = Client().get(path)
|
||
assert response.status_code == 200
|
||
html = response.content.decode()
|
||
assert OLD in html and NEW in html and 'method="post"' in html
|
||
assert mail_of(adapters) == NEW and AuditEntry.objects.count() == count
|
||
assert PendingEmailChange.objects.get(kind="undo").used_at is None
|
||
|
||
|
||
def test_undoing_restores_the_address_ends_the_sessions_and_tells_the_admin(people, adapters):
|
||
path = confirmed_undo_path(adapters)
|
||
alice_one, alice_two = signed_in(adapters), signed_in(adapters)
|
||
bob = signed_in(adapters, "bob")
|
||
response = Client().post(path)
|
||
html = response.content.decode()
|
||
assert response.status_code == 200 and "Please change your password now." in html
|
||
assert mail_of(adapters) == OLD
|
||
assert alice_one.get("/").status_code == 302 and alice_two.get("/").status_code == 302
|
||
assert bob.get("/").status_code == 200 # only this user's sessions end
|
||
assert UserSession.objects.filter(username="alice").count() == 0
|
||
assert PendingEmailChange.objects.get(kind="undo").used_at is not None
|
||
[admin] = mails(ADMIN)
|
||
assert admin.template == "email_undone_admin" and "alice" in admin.body_text
|
||
assert path.rsplit("/", 1)[1] not in admin.body_text and OLD not in admin.body_text
|
||
entry = AuditEntry.objects.get(action="email.undone")
|
||
assert entry.detail == {"sessions_ended": 3, "admin_notice": "queued"}
|
||
assert Client().post(path).status_code == 410 and Client().get(path).status_code == 410
|
||
|
||
|
||
def test_undoing_also_drops_a_request_made_in_the_meantime(people, adapters):
|
||
path = confirmed_undo_path(adapters)
|
||
signed_in(adapters)
|
||
pending_client = signed_in(adapters)
|
||
ask(pending_client, new="thief@example.test")
|
||
stolen = link_in(mails("thief@example.test")[0])
|
||
assert Client().post(path).status_code == 200
|
||
assert Client().post(stolen).status_code == 410
|
||
assert mail_of(adapters) == OLD
|
||
|
||
|
||
def test_the_undo_is_unavailable_when_the_directory_is_down(people, adapters):
|
||
path = confirmed_undo_path(adapters)
|
||
other = signed_in(adapters)
|
||
adapters.directory.unavailable = True
|
||
assert Client().post(path).status_code == 503
|
||
adapters.directory.unavailable = False
|
||
assert PendingEmailChange.objects.get(kind="undo").used_at is None
|
||
assert other.get("/").status_code == 200 and mails(ADMIN) == []
|
||
assert "email.undone" not in actions()
|
||
|
||
|
||
def test_an_undo_after_another_change_changes_nothing(people, adapters):
|
||
path = confirmed_undo_path(adapters)
|
||
adapters.directory.update_attributes("alice", {"mail": "third@example.test"})
|
||
other = signed_in(adapters)
|
||
response = Client().post(path)
|
||
assert response.status_code == 409 and "changed again" in response.content.decode()
|
||
assert mail_of(adapters) == "third@example.test"
|
||
assert other.get("/").status_code == 200 and mails(ADMIN) == []
|
||
assert PendingEmailChange.objects.get(kind="undo").used_at is None
|
||
|
||
|
||
def test_an_undo_without_an_admin_address_still_succeeds_and_logs_an_error(
|
||
people, adapters, settings, caplog
|
||
):
|
||
settings.ADMIN_NOTIFY_EMAIL = ""
|
||
path = confirmed_undo_path(adapters)
|
||
with caplog.at_level(logging.ERROR):
|
||
response = Client().post(path)
|
||
assert response.status_code == 200 and mail_of(adapters) == OLD
|
||
assert mails(ADMIN) == [] and len(mails()) == 2 # the two earlier mails only
|
||
[record] = [r for r in caplog.records if "ADMIN_NOTIFY_EMAIL" in r.getMessage()]
|
||
assert record.levelno == logging.ERROR and "user=alice" in record.getMessage()
|
||
assert OLD not in caplog.text and NEW not in caplog.text
|
||
assert AuditEntry.objects.get(action="email.undone").detail["admin_notice"] == "skipped"
|
||
|
||
|
||
def test_an_expired_undo_link_is_dead(people, adapters):
|
||
path = confirmed_undo_path(adapters)
|
||
PendingEmailChange.objects.filter(kind="undo").update(
|
||
expires_at=timezone.now() - timedelta(seconds=1)
|
||
)
|
||
assert Client().get(path).status_code == 410
|
||
assert Client().post(path).status_code == 410
|
||
assert mail_of(adapters) == NEW
|
||
|
||
|
||
# -- guards -----------------------------------------
|
||
|
||
|
||
def test_the_page_needs_a_sign_in(people):
|
||
client = Client()
|
||
assert client.get("/email").status_code == 302
|
||
assert "/auth/login" in client.get("/email")["Location"]
|
||
assert client.post("/email", {"new_email": NEW, "current_password": CURRENT}).status_code == 302
|
||
assert mails() == []
|
||
|
||
|
||
def test_wrong_methods_are_refused(people, adapters):
|
||
path = request_link(adapters)
|
||
client = signed_in(adapters)
|
||
for target in ("/email", path, "/email/undo/abc"):
|
||
assert client.put(target).status_code == 405
|
||
assert client.delete(target).status_code == 405
|
||
assert mail_of(adapters) == OLD
|
||
|
||
|
||
def test_a_post_without_a_csrf_token_changes_nothing(people, adapters):
|
||
path = request_link(adapters)
|
||
guarded = Client(enforce_csrf_checks=True)
|
||
assert guarded.post(path).status_code == 403
|
||
assert mail_of(adapters) == OLD and PendingEmailChange.objects.get().used_at is None
|
||
undo = confirmed_undo_path(adapters)
|
||
assert guarded.post(undo).status_code == 403
|
||
assert mail_of(adapters) == NEW
|
||
signed = Client(enforce_csrf_checks=True)
|
||
signed.cookies.update(signed_in(adapters).cookies)
|
||
before = len(mails())
|
||
assert (
|
||
signed.post(
|
||
"/email", {"new_email": "x@example.test", "current_password": CURRENT}
|
||
).status_code
|
||
== 403
|
||
)
|
||
assert len(mails()) == before
|
||
|
||
|
||
def test_the_form_has_no_username_and_ignores_one(people, adapters):
|
||
client = signed_in(adapters)
|
||
html = client.get("/email").content.decode()
|
||
assert "username" not in html.lower().replace("user name", "")
|
||
client.post(
|
||
"/email", {"new_email": NEW, "current_password": CURRENT, "username": "bob", "sam": "bob"}
|
||
)
|
||
assert PendingEmailChange.objects.get().user_key == "alice"
|
||
assert mail_of(adapters, "bob") == "bob@example.test"
|
||
|
||
|
||
# -- failures after the directory changed -----------------------------------------
|
||
|
||
|
||
def test_a_failure_after_the_confirming_write_is_reported_as_unclear(
|
||
people, adapters, monkeypatch, caplog
|
||
):
|
||
path = request_link(adapters)
|
||
|
||
def broken(*args, **kwargs):
|
||
raise RuntimeError("database trouble " + OLD)
|
||
|
||
monkeypatch.setattr(services.mailer, "enqueue", broken)
|
||
with caplog.at_level(logging.ERROR):
|
||
response = Client().post(path)
|
||
assert response.status_code == 500 and "Result unclear" in response.content.decode()
|
||
assert mail_of(adapters) == NEW # the directory did change
|
||
assert "email.changed" not in actions()
|
||
entry = AuditEntry.objects.get(action="email.change_failed")
|
||
assert entry.detail == {"reason": "unclear", "step": "queue_notice"}
|
||
record = next(r for r in caplog.records if "did not record it" in r.getMessage())
|
||
assert record.levelno == logging.ERROR and "user=alice" in record.getMessage()
|
||
assert "RuntimeError" in record.getMessage()
|
||
for secret in (OLD, NEW, path.rsplit("/", 1)[1], "database trouble"):
|
||
assert secret not in caplog.text
|
||
|
||
|
||
def test_a_failure_after_the_undoing_write_still_ends_the_sessions(
|
||
people, adapters, monkeypatch, caplog
|
||
):
|
||
path = confirmed_undo_path(adapters)
|
||
other = signed_in(adapters)
|
||
|
||
def broken(*args, **kwargs):
|
||
raise RuntimeError("database trouble")
|
||
|
||
monkeypatch.setattr(services.mailer, "enqueue", broken)
|
||
with caplog.at_level(logging.ERROR):
|
||
response = Client().post(path)
|
||
assert response.status_code == 500
|
||
assert mail_of(adapters) == OLD and other.get("/").status_code == 302
|
||
assert AuditEntry.objects.get(action="email.undo_failed").detail["reason"] == "unclear"
|
||
assert "user=alice" in caplog.text and OLD not in caplog.text
|
||
|
||
|
||
def test_a_failure_before_any_write_leaves_the_request_clean(people, adapters, monkeypatch):
|
||
client = signed_in(adapters)
|
||
|
||
def broken(*args, **kwargs):
|
||
raise RuntimeError("boom")
|
||
|
||
monkeypatch.setattr(services.mailer, "enqueue", broken)
|
||
with pytest.raises(RuntimeError):
|
||
ask(client)
|
||
assert PendingEmailChange.objects.count() == 0
|
||
assert AuditEntry.objects.get(action="email.change_failed").detail == {"reason": "error"}
|
||
|
||
|
||
# -- no secrets anywhere -----------------------------------------
|
||
|
||
|
||
def test_no_password_token_or_address_reaches_logs_or_the_audit(people, adapters, caplog):
|
||
with caplog.at_level(logging.DEBUG):
|
||
client = signed_in(adapters)
|
||
ask(client, password=WRONG)
|
||
ask(client)
|
||
confirm = link_in(mails(NEW)[0])
|
||
Client().get(confirm)
|
||
Client().post(confirm)
|
||
undo = link_in(mails(OLD)[0])
|
||
Client().post(undo)
|
||
Client().post(undo)
|
||
tokens = [confirm.rsplit("/", 1)[1], undo.rsplit("/", 1)[1]]
|
||
stored = " ".join(str(e.detail) + e.action + e.target + e.ip for e in AuditEntry.objects.all())
|
||
stored += " ".join(str(dict(s.get_decoded())) for s in UserSession.objects.all())
|
||
for secret in (CURRENT, WRONG, OLD, NEW, ADMIN, *tokens):
|
||
assert secret not in caplog.text, secret
|
||
assert secret not in stored, secret
|
||
hashes = " ".join(PendingEmailChange.objects.values_list("token_hash", flat=True))
|
||
assert all(token not in hashes for token in tokens)
|
||
|
||
|
||
def test_the_password_field_is_masked_in_error_reports(people, adapters, monkeypatch):
|
||
from django.views.debug import SafeExceptionReporterFilter
|
||
|
||
def crash(*args, **kwargs):
|
||
raise RuntimeError("crash")
|
||
|
||
monkeypatch.setattr(services, "request_email_change", crash)
|
||
client = signed_in(adapters)
|
||
client.raise_request_exception = False
|
||
response = ask(client)
|
||
assert response.status_code == 500
|
||
request = response.wsgi_request
|
||
shown = SafeExceptionReporterFilter().get_post_parameters(request)
|
||
assert shown["current_password"] == SafeExceptionReporterFilter.cleansed_substitute
|
||
|
||
|
||
def test_the_migrations_are_complete(db):
|
||
call_command("makemigrations", check=True, dry_run=True, verbosity=0)
|
||
|
||
|
||
def test_an_unavailable_directory_error_type_is_what_the_service_raises(people, adapters):
|
||
adapters.directory.unavailable = True
|
||
from portal.accounts.sessions import SessionUser
|
||
|
||
with pytest.raises(DirectoryUnavailable):
|
||
services.request_email_change(SessionUser.from_sam("alice"), NEW, CURRENT, ip="127.0.0.1")
|
||
|
||
|
||
def test_a_link_token_is_blanked_in_the_request_log_path():
|
||
from portal.config.logging import redact
|
||
|
||
assert redact("/email/confirm/abc-DEF_123") == "/email/confirm/<redacted>"
|
||
assert redact("Internal Server Error: /email/undo/abc") == (
|
||
"Internal Server Error: /email/undo/<redacted>"
|
||
)
|
||
assert redact("/password") == "/password"
|
||
|
||
|
||
def test_the_configured_handler_blanks_tokens_from_every_logger():
|
||
import io
|
||
import logging.config
|
||
|
||
from portal.config.logging import build_logging
|
||
|
||
root = logging.getLogger()
|
||
saved = (root.handlers[:], root.level)
|
||
request_logger = logging.getLogger("django.request")
|
||
saved_request = (request_logger.handlers[:], request_logger.level)
|
||
try:
|
||
logging.config.dictConfig(build_logging("INFO"))
|
||
handler = root.handlers[0]
|
||
stream = io.StringIO()
|
||
handler.setStream(stream)
|
||
logging.getLogger("django.security.csrf").warning(
|
||
"Forbidden (CSRF token missing): /email/confirm/SECRET-token_123"
|
||
)
|
||
output = stream.getvalue()
|
||
finally:
|
||
root.handlers[:], root.level = saved
|
||
request_logger.handlers[:], request_logger.level = saved_request
|
||
assert "SECRET-token_123" not in output and "/email/confirm/<redacted>" in output
|