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:
Ebbe Baß
2026-10-19 10:05:41 +02:00
co-authored by Claude Sonnet 5.5
parent 3fd8f27127
commit 94c1d39c9d
35 changed files with 1792 additions and 14 deletions
+693
View File
@@ -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 (
"&ndash;" 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