a9830c42d8
- app/main.py serves the SPA build (SPA_DIST_DIR, default frontend/dist): mounts /assets and a GET catch-all returning index.html for client routes; catch-all 404s on /api/*, never swallows /docs, /openapi.json, /static, assets, ingestion/ticktick/status; skips SPA serving when dist absent (backend-only CI) - delete app/api/routes/pages.py, app/api/routes/auth.py, app/templates/ (all replaced by /api/* + SPA; auth service layer kept) - remove/replace Jinja page tests (JSON coverage already in test_api_*); add tests/test_spa_hosting.py for the fallback contract - regenerate openapi/ (Jinja paths gone) and frontend schema.d.ts
221 lines
6.9 KiB
Python
221 lines
6.9 KiB
Python
"""SMTP service-layer unit tests.
|
|
|
|
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
|
|
|
|
from app.config import Settings
|
|
from app.services.email import (
|
|
EmailDeliveryError,
|
|
get_smtp_config,
|
|
is_smtp_ready,
|
|
send_public_ip_changed_email,
|
|
send_smtp_test_email,
|
|
)
|
|
|
|
|
|
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",
|
|
"smtp_from_name": "Home Automation",
|
|
"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"
|
|
assert smtp_config.from_name == "Home Automation"
|
|
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
|
|
|
|
def send_message(self, message, from_addr=None, to_addrs=None):
|
|
sent["subject"] = message["Subject"]
|
|
sent["from"] = message["From"]
|
|
sent["to"] = message["To"]
|
|
sent["body"] = message.get_content()
|
|
sent["envelope_from"] = from_addr
|
|
sent["envelope_to"] = to_addrs
|
|
|
|
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"
|
|
assert sent["from"] == "Home Automation <sender@example.com>"
|
|
assert sent["to"] == "recipient@example.com"
|
|
assert sent["envelope_from"] == "sender@example.com"
|
|
assert sent["envelope_to"] == ["recipient@example.com"]
|
|
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
|
|
|
|
def send_message(self, message, from_addr=None, to_addrs=None):
|
|
sent["subject"] = message["Subject"]
|
|
sent["from"] = message["From"]
|
|
sent["envelope_from"] = from_addr
|
|
|
|
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"
|
|
assert sent["from"] == "Home Automation <sender@example.com>"
|
|
assert sent["envelope_from"] == "sender@example.com"
|
|
|
|
|
|
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)
|
|
|
|
|
|
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"]
|