Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,9 @@ PUSHOVER_TOKEN=<Pushover Token>
# Comma-separated to notify multiple users/groups, e.g. <key1>,<key2>
PUSHOVER_USER_KEY=<Pushover User Key>

# ── hCaptcha / Rapid API ───────────────────────────────────────────────────────
# ── hCaptcha / UserCheck ───────────────────────────────────────────────────────
HCAPTCHA_SECRET_KEY=<hCaptcha Secret Key>
RAPID_API_KEY=<Rapid API Key>
RAPID_API_HOST=<Rapid API Host>
USERCHECK_API_KEY=<UserCheck API Key>

# ── OpenCollective donor sync ──────────────────────────────────────────────────
OPENCOLLECTIVE_API_KEY=<OpenCollective Personal Token>
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ jobs:
PUSHOVER_TOKEN: "dummy"
PUSHOVER_USER_KEY: "dummy"
HCAPTCHA_SECRET_KEY: "dummy"
RAPID_API_KEY: "dummy"
RAPID_API_HOST: "dummy"
USERCHECK_API_KEY: "dummy"
OPENCOLLECTIVE_API_KEY: "dummy"
STATIC_ROOT: "/tmp/static"
MEDIA_ROOT: "/tmp/media"
Expand Down
23 changes: 20 additions & 3 deletions locale/it/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -6515,9 +6515,22 @@ msgstr "L'email esiste già"
msgid "Temporary email addresses are not allowed."
msgstr "Gli indirizzi email temporanei non sono ammessi."

#: users/forms.py:61
msgid "Error creating account. Contact the site administrator."
msgstr "Errore nella creazione dell'account. Contatta l'amministratore del sito."
#: users/forms.py
msgid "Email forwarding addresses are not allowed."
msgstr "Gli indirizzi email di inoltro non sono ammessi."

#: users/forms.py
#, python-format
msgid "Did you mean %(suggestion)s?"
msgstr "Intendevi %(suggestion)s?"

#: users/forms.py
msgid ""
"We couldn't verify your email address right now. Please try again in a "
"moment."
msgstr ""
"Al momento non è stato possibile verificare il tuo indirizzo email. Riprova "
"tra qualche istante."

#: users/forms.py:78
msgid "Required. Enter a valid email address."
Expand Down Expand Up @@ -6593,6 +6606,10 @@ msgstr ""
"Al momento non è stato possibile verificare il captcha. Riprova tra qualche "
"minuto."

#: users/views.py
msgid "Please complete the captcha and try again."
msgstr "Completa il captcha e riprova."

#: users/login_throttle.py
msgid "Too many failed attempts. Please try again in 15 minutes."
msgstr "Troppi tentativi non riusciti. Riprova tra 15 minuti."
Expand Down
5 changes: 2 additions & 3 deletions metron.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,9 @@ EMAIL_FROM=
PUSHOVER_TOKEN=
PUSHOVER_USER_KEY=

# ── hCaptcha / Rapid API ───────────────────────────────────────────────────────
# ── hCaptcha / UserCheck ───────────────────────────────────────────────────────
HCAPTCHA_SECRET_KEY=
RAPID_API_KEY=
RAPID_API_HOST=
USERCHECK_API_KEY=

# ── OpenCollective donor sync ──────────────────────────────────────────────────
OPENCOLLECTIVE_API_KEY=
Expand Down
4 changes: 2 additions & 2 deletions metron/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,8 +368,8 @@
}

HCAPTCHA_SECRET_KEY = config("HCAPTCHA_SECRET_KEY")
RAPID_API_KEY = config("RAPID_API_KEY")
RAPID_API_HOST = config("RAPID_API_HOST")
# Optional: without a key, signups are checked against the local blocklists only.
USERCHECK_API_KEY = config("USERCHECK_API_KEY", default="")

OPENCOLLECTIVE_API_KEY = config("OPENCOLLECTIVE_API_KEY")
OPENCOLLECTIVE_SLUG = config("OPENCOLLECTIVE_SLUG", default="metron")
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ dependencies = [
"bleach[css]",
"boto3",
"chartkick",
"disposable-email-domains>=0.0.281",
"django",
"django-countries",
"django-filter",
Expand Down
25 changes: 25 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,31 @@ def isolated_login_throttle():
yield


CLEAN_EMAIL_VERDICT = {
"disposable": False,
"spam": False,
"relay_domain": False,
"mx": True,
"suggested_domain": None,
}


@pytest.fixture(autouse=True)
def isolated_usercheck():
"""Keep tests off the real UserCheck API, and give each test its own verdict
cache, alert flag and credit status in the shared Redis cache. Forms see a clean verdict for
any address not caught by the local blocklists; tests that need another verdict
re-patch users.forms.check_email_domain, and the inner patch takes precedence."""
suffix = uuid.uuid4().hex
with (
patch("users.utils.CACHE_PREFIX", f"usercheck:test:{suffix}"),
patch("users.utils.ALERT_KEY", f"usercheck:alerted:test:{suffix}"),
patch("users.utils.CREDITS_KEY", f"usercheck:has_credits:test:{suffix}"),
patch("users.forms.check_email_domain", return_value=CLEAN_EMAIL_VERDICT),
):
yield


@pytest.fixture(autouse=True)
def isolated_api_throttle():
"""Give each test its own DRF throttle history. It's keyed by user pk in the
Expand Down
23 changes: 20 additions & 3 deletions tests/users/test_email_change.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

HTML_OK_CODE = 200
HTTP_REDIRECT_FOUND_CODE = 302
NEW_EMAIL = "new-address@gmail.com" # Allowlisted domain: skips the RapidAPI check.
NEW_EMAIL = "new-address@gmail.com" # Allowlisted domain: skips the UserCheck lookup.


def _profile_data(user, **overrides):
Expand Down Expand Up @@ -138,15 +138,32 @@ def test_rejects_disposable_address(self, auto_login_user, test_password):

def test_runs_domain_check_for_unlisted_domains(self, auto_login_user, test_password):
client, user = auto_login_user()
blocked = {"block": True, "disposable": True}
blocked = {
"disposable": True,
"spam": False,
"relay_domain": False,
"mx": True,
"suggested_domain": None,
}
with patch("users.forms.check_email_domain", return_value=blocked) as mock_check:
resp = client.post(
reverse("change_profile"),
_profile_data(user, email="x@example.org", current_password=test_password),
)
mock_check.assert_called_once_with("x@example.org")
mock_check.assert_called_once_with("x@example.org", "example.org")
assert resp.context["form"].errors["email"]

@pytest.mark.parametrize("password", ["", "not-my-password"])
def test_lookup_waits_for_correct_password(self, auto_login_user, password):
client, user = auto_login_user()
with patch("users.forms.check_email_domain") as mock_check:
resp = client.post(
reverse("change_profile"),
_profile_data(user, email="x@example.org", current_password=password),
)
mock_check.assert_not_called()
assert resp.context["form"].errors["current_password"]

def test_send_failure_saves_nothing(self, auto_login_user, test_password, test_email):
client, user = auto_login_user()
with patch(
Expand Down
103 changes: 103 additions & 0 deletions tests/users/test_email_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Tests for users.forms.validate_email_allowed, shared by signup and email changes."""

from unittest.mock import patch

import pytest
from django.core.exceptions import ValidationError

from users.forms import validate_email_allowed

CHECK = "users.forms.check_email_domain"
CLEAN = {
"disposable": False,
"spam": False,
"relay_domain": False,
"mx": True,
"suggested_domain": None,
}

pytestmark = pytest.mark.django_db


def _error(email, verdict=CLEAN):
with patch(CHECK, return_value=verdict) as mock_check:
try:
validate_email_allowed(email)
except ValidationError as exc:
return exc.messages[0], mock_check
return None, mock_check


@pytest.mark.parametrize(
"email",
[
"x@mailinator.com", # disposable-email-domains package
"x@inbox.mailinator.com", # subdomain of a package-listed domain
"x@duck.com", # hand-kept blocklist
"x@Duck.COM",
],
)
def test_local_blocklists_reject_without_lookup(email):
message, mock_check = _error(email)
assert message == "Temporary email addresses are not allowed."
mock_check.assert_not_called()


def test_allowlisted_domain_skips_lookup():
message, mock_check = _error("x@Gmail.com")
assert message is None
mock_check.assert_not_called()


def test_clean_verdict_is_allowed():
message, mock_check = _error("x@example.org")
assert message is None
mock_check.assert_called_once_with("x@example.org", "example.org")


def test_lookup_gets_normalized_domain():
message, mock_check = _error("x@Example.ORG")
assert message is None
mock_check.assert_called_once_with("x@Example.ORG", "example.org")


def test_fails_open_without_verdict():
message, _mock = _error("x@example.org", verdict=None)
assert message is None


@pytest.mark.parametrize(
("overrides", "expected"),
[
({"disposable": True}, "Temporary email addresses are not allowed."),
({"spam": True}, "Temporary email addresses are not allowed."),
({"relay_domain": True}, "Email forwarding addresses are not allowed."),
({"mx": False}, "Email address is not valid."),
({"mx": False, "suggested_domain": "gmail.com"}, "Did you mean x@gmail.com?"),
# An unknown MX result isn't grounds to reject.
({"mx": None}, None),
# A suggestion alone doesn't block an address that can receive mail.
({"suggested_domain": "gmail.com"}, None),
],
)
def test_verdict_mapping(overrides, expected):
message, _mock = _error("x@example.org", verdict={**CLEAN, **overrides})
assert message == expected


def test_malformed_address_is_rejected():
message, _mock = _error("x@example.org", verdict={"invalid": True})
assert message == "Email address is not valid."


def test_rate_limited_lookup_asks_user_to_retry():
message, _mock = _error("x@example.org", verdict={"rate_limited": True})
assert message == (
"We couldn't verify your email address right now. Please try again in a moment."
)


def test_address_without_domain_is_rejected():
message, mock_check = _error("no-at-sign")
assert message == "Email address is not valid."
mock_check.assert_not_called()
2 changes: 1 addition & 1 deletion tests/users/test_login_throttle.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
HTML_OK_CODE = 200
HTTP_REDIRECT_FOUND_CODE = 302
LOCKED_TEXT = "Too many failed attempts"
NEW_EMAIL = "new-address@gmail.com" # Allowlisted domain: skips the RapidAPI check.
NEW_EMAIL = "new-address@gmail.com" # Allowlisted domain: skips the UserCheck lookup.


def _login(client, username, password, ip="203.0.113.10"):
Expand Down
Loading
Loading