2026-06-13 11:29:14 +02:00
|
|
|
"""SMTP service-layer unit tests.
|
2026-04-29 12:11:10 +02:00
|
|
|
|
2026-06-13 11:29:14 +02:00
|
|
|
Jinja-based HTTP flow tests (POST /config, POST /config/smtp/test via form) were
|
|
|
|
|
removed in M2-T11 when the Jinja routes were deleted. HTTP-level SMTP test
|
|
|
|
|
endpoint coverage lives in test_api_config.py.
|
|
|
|
|
"""
|
|
|
|
|
import smtplib
|
2026-04-29 12:11:10 +02:00
|
|
|
|
|
|
|
|
from app.config import Settings
|
2026-04-29 13:03:12 +02:00
|
|
|
from app.services.email import (
|
|
|
|
|
EmailDeliveryError,
|
|
|
|
|
get_smtp_config,
|
|
|
|
|
is_smtp_ready,
|
|
|
|
|
send_public_ip_changed_email,
|
|
|
|
|
send_smtp_test_email,
|
|
|
|
|
)
|
2026-04-29 12:11:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _smtp_settings(**overrides) -> Settings:
|
|
|
|
|
payload = {
|
|
|
|
|
"app_env": "development",
|
|
|
|
|
"app_hostname": "localhost:8000",
|
|
|
|
|
"app_database_url": "sqlite:///./data/app.db",
|
|
|
|
|
"auth_bootstrap_username": "admin",
|
|
|
|
|
"auth_bootstrap_password": "secret-password",
|
|
|
|
|
"smtp_enabled": True,
|
|
|
|
|
"smtp_host": "smtp.example.com",
|
|
|
|
|
"smtp_port": 587,
|
|
|
|
|
"smtp_username": "smtp-user",
|
|
|
|
|
"smtp_password": "super-secret-password",
|
2026-04-29 13:03:12 +02:00
|
|
|
"smtp_from_name": "Home Automation",
|
2026-04-29 12:11:10 +02:00
|
|
|
"smtp_from_address": "sender@example.com",
|
|
|
|
|
"smtp_to_address": "recipient@example.com",
|
|
|
|
|
"smtp_use_starttls": True,
|
|
|
|
|
}
|
|
|
|
|
payload.update(overrides)
|
|
|
|
|
return Settings(_env_file=None, **payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_smtp_config_reads_runtime_values() -> None:
|
|
|
|
|
settings = _smtp_settings(smtp_port=2525, smtp_use_starttls=False)
|
|
|
|
|
|
|
|
|
|
smtp_config = get_smtp_config(settings)
|
|
|
|
|
|
|
|
|
|
assert smtp_config.host == "smtp.example.com"
|
|
|
|
|
assert smtp_config.port == 2525
|
|
|
|
|
assert smtp_config.username == "smtp-user"
|
|
|
|
|
assert smtp_config.password == "super-secret-password"
|
2026-04-29 13:03:12 +02:00
|
|
|
assert smtp_config.from_name == "Home Automation"
|
2026-04-29 12:11:10 +02:00
|
|
|
assert smtp_config.from_address == "sender@example.com"
|
|
|
|
|
assert smtp_config.to_address == "recipient@example.com"
|
|
|
|
|
assert smtp_config.use_starttls is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_smtp_test_readiness_does_not_require_smtp_enabled() -> None:
|
|
|
|
|
settings = _smtp_settings(smtp_enabled=False)
|
|
|
|
|
|
|
|
|
|
assert is_smtp_ready(settings) is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_send_smtp_test_email_success(monkeypatch) -> None:
|
|
|
|
|
sent = {}
|
|
|
|
|
|
|
|
|
|
class FakeSMTP:
|
|
|
|
|
def __init__(self, host, port, timeout):
|
|
|
|
|
sent["host"] = host
|
|
|
|
|
sent["port"] = port
|
|
|
|
|
sent["timeout"] = timeout
|
|
|
|
|
|
|
|
|
|
def __enter__(self):
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def ehlo(self):
|
|
|
|
|
sent["ehlo"] = sent.get("ehlo", 0) + 1
|
|
|
|
|
|
|
|
|
|
def starttls(self):
|
|
|
|
|
sent["starttls"] = True
|
|
|
|
|
|
|
|
|
|
def login(self, username, password):
|
|
|
|
|
sent["username"] = username
|
|
|
|
|
sent["password"] = password
|
|
|
|
|
|
2026-04-29 13:03:12 +02:00
|
|
|
def send_message(self, message, from_addr=None, to_addrs=None):
|
2026-04-29 12:11:10 +02:00
|
|
|
sent["subject"] = message["Subject"]
|
|
|
|
|
sent["from"] = message["From"]
|
|
|
|
|
sent["to"] = message["To"]
|
|
|
|
|
sent["body"] = message.get_content()
|
2026-04-29 13:03:12 +02:00
|
|
|
sent["envelope_from"] = from_addr
|
|
|
|
|
sent["envelope_to"] = to_addrs
|
2026-04-29 12:11:10 +02:00
|
|
|
|
|
|
|
|
monkeypatch.setattr("app.services.email.smtplib.SMTP", FakeSMTP)
|
|
|
|
|
|
|
|
|
|
send_smtp_test_email(_smtp_settings())
|
|
|
|
|
|
|
|
|
|
assert sent["host"] == "smtp.example.com"
|
|
|
|
|
assert sent["port"] == 587
|
|
|
|
|
assert sent["timeout"] == 10
|
|
|
|
|
assert sent["starttls"] is True
|
|
|
|
|
assert sent["username"] == "smtp-user"
|
|
|
|
|
assert sent["password"] == "super-secret-password"
|
|
|
|
|
assert sent["subject"] == "Home Automation SMTP Test"
|
2026-04-29 13:03:12 +02:00
|
|
|
assert sent["from"] == "Home Automation <sender@example.com>"
|
2026-04-29 12:11:10 +02:00
|
|
|
assert sent["to"] == "recipient@example.com"
|
2026-04-29 13:03:12 +02:00
|
|
|
assert sent["envelope_from"] == "sender@example.com"
|
|
|
|
|
assert sent["envelope_to"] == ["recipient@example.com"]
|
2026-04-29 12:11:10 +02:00
|
|
|
assert "This is a test email" in sent["body"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_send_smtp_test_email_does_not_require_smtp_enabled(monkeypatch) -> None:
|
|
|
|
|
sent = {}
|
|
|
|
|
|
|
|
|
|
class FakeSMTP:
|
|
|
|
|
def __init__(self, host, port, timeout):
|
|
|
|
|
sent["host"] = host
|
|
|
|
|
|
|
|
|
|
def __enter__(self):
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def ehlo(self):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def starttls(self):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def login(self, username, password):
|
|
|
|
|
return None
|
|
|
|
|
|
2026-04-29 13:03:12 +02:00
|
|
|
def send_message(self, message, from_addr=None, to_addrs=None):
|
2026-04-29 12:11:10 +02:00
|
|
|
sent["subject"] = message["Subject"]
|
2026-04-29 13:03:12 +02:00
|
|
|
sent["from"] = message["From"]
|
|
|
|
|
sent["envelope_from"] = from_addr
|
2026-04-29 12:11:10 +02:00
|
|
|
|
|
|
|
|
monkeypatch.setattr("app.services.email.smtplib.SMTP", FakeSMTP)
|
|
|
|
|
|
|
|
|
|
send_smtp_test_email(_smtp_settings(smtp_enabled=False))
|
|
|
|
|
|
|
|
|
|
assert sent["host"] == "smtp.example.com"
|
|
|
|
|
assert sent["subject"] == "Home Automation SMTP Test"
|
2026-04-29 13:03:12 +02:00
|
|
|
assert sent["from"] == "Home Automation <sender@example.com>"
|
|
|
|
|
assert sent["envelope_from"] == "sender@example.com"
|
2026-04-29 12:11:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_send_smtp_test_email_failure_sanitizes_password(monkeypatch) -> None:
|
|
|
|
|
class FakeSMTP:
|
|
|
|
|
def __init__(self, host, port, timeout):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def __enter__(self):
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def ehlo(self):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def starttls(self):
|
|
|
|
|
raise smtplib.SMTPException("authentication failed for super-secret-password")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("app.services.email.smtplib.SMTP", FakeSMTP)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
send_smtp_test_email(_smtp_settings())
|
|
|
|
|
assert False, "expected EmailDeliveryError"
|
|
|
|
|
except EmailDeliveryError as exc:
|
|
|
|
|
assert "super-secret-password" not in str(exc)
|
|
|
|
|
assert "[redacted]" in str(exc)
|
|
|
|
|
|
|
|
|
|
|
2026-04-29 13:03:12 +02:00
|
|
|
def test_send_public_ip_changed_email_contains_expected_english_content(monkeypatch) -> None:
|
|
|
|
|
sent = {}
|
|
|
|
|
|
|
|
|
|
class FakeSMTP:
|
|
|
|
|
def __init__(self, host, port, timeout):
|
|
|
|
|
sent["host"] = host
|
|
|
|
|
|
|
|
|
|
def __enter__(self):
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def ehlo(self):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def starttls(self):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def login(self, username, password):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def send_message(self, message, from_addr=None, to_addrs=None):
|
|
|
|
|
sent["subject"] = message["Subject"]
|
|
|
|
|
sent["body"] = message.get_content()
|
|
|
|
|
sent["from"] = message["From"]
|
|
|
|
|
sent["envelope_from"] = from_addr
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("app.services.email.smtplib.SMTP", FakeSMTP)
|
|
|
|
|
|
|
|
|
|
send_public_ip_changed_email(
|
|
|
|
|
_smtp_settings(),
|
|
|
|
|
previous_ipv4="203.0.113.10",
|
|
|
|
|
current_ipv4="198.51.100.25",
|
|
|
|
|
detected_at=__import__("datetime").datetime(2026, 4, 29, 10, 0, tzinfo=__import__("datetime").UTC),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert sent["subject"] == "Public IP changed"
|
|
|
|
|
assert sent["from"] == "Home Automation <sender@example.com>"
|
|
|
|
|
assert sent["envelope_from"] == "sender@example.com"
|
|
|
|
|
assert "Your public IPv4 address has changed." in sent["body"]
|
|
|
|
|
assert "Previous IP: 203.0.113.10" in sent["body"]
|
|
|
|
|
assert "Current IP: 198.51.100.25" in sent["body"]
|
|
|
|
|
assert "Detected at: 2026-04-29 10:00:00 UTC" in sent["body"]
|
|
|
|
|
assert "update the trusted IP manually" in sent["body"]
|