feat: add e-mail change with confirmation and undo
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
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
3fd8f27127
commit
94c1d39c9d
@@ -178,3 +178,13 @@ def test_the_mail_settings_are_required(valid_env, name):
|
||||
del valid_env[name]
|
||||
with pytest.raises(ConfigError, match=name):
|
||||
load_config(valid_env)
|
||||
|
||||
|
||||
def test_the_admin_notice_address_is_optional_and_checked(valid_env):
|
||||
assert load_config(valid_env).admin_notify_email == ""
|
||||
assert load_config({**valid_env, "ADMIN_NOTIFY_EMAIL": " "}).admin_notify_email == ""
|
||||
good = {**valid_env, "ADMIN_NOTIFY_EMAIL": "admin@example.test"}
|
||||
assert load_config(good).admin_notify_email == "admin@example.test"
|
||||
with pytest.raises(ConfigError, match="ADMIN_NOTIFY_EMAIL") as info:
|
||||
load_config({**valid_env, "ADMIN_NOTIFY_EMAIL": "not an address"})
|
||||
assert "not an address" not in str(info.value)
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user