CmdForge/tests/test_email.py

174 lines
6.1 KiB
Python

"""Tests for transactional email and password-reset delivery safety."""
from __future__ import annotations
import hashlib
import logging
from unittest.mock import patch
import requests
from flask import Flask
from cmdforge.registry.app import create_app, password_hasher
from cmdforge.registry.db import connect_db
from cmdforge.web.email import send_email
def _email_app(**config) -> Flask:
app = Flask(__name__)
defaults = {
"CMDFORGE_ENV": "development",
"MAIL_TRANSPORT": "disabled",
"MAIL_FROM": "CmdForge <noreply@example.com>",
"MAIL_TIMEOUT": 10,
"RESEND_API_KEY": "",
}
defaults.update(config)
app.config.update(defaults)
return app
def test_disabled_delivery_does_not_log_message_or_recipient(caplog):
app = _email_app()
with app.app_context(), caplog.at_level(logging.INFO):
assert not send_email(
"private@example.com", "Reset subject", "secret reset body", "secret text"
)
assert "private@example.com" not in caplog.text
assert "secret reset body" not in caplog.text
assert "secret text" not in caplog.text
def test_console_transport_is_rejected_in_production(caplog):
app = _email_app(CMDFORGE_ENV="production", MAIL_TRANSPORT="console")
with app.app_context(), caplog.at_level(logging.INFO):
assert not send_email("private@example.com", "subject", "body")
assert "private@example.com" not in caplog.text
assert "body" not in caplog.text
def test_resend_transport_sends_expected_request(monkeypatch):
app = _email_app(MAIL_TRANSPORT="resend", RESEND_API_KEY="test-api-key")
captured = {}
class Response:
def raise_for_status(self):
return None
def fake_post(url, **kwargs):
captured["url"] = url
captured.update(kwargs)
return Response()
monkeypatch.setattr(requests, "post", fake_post)
with app.app_context():
assert send_email("person@example.com", "subject", "<p>body</p>", "body")
assert captured["url"] == "https://api.resend.com/emails"
assert captured["headers"]["Authorization"] == "Bearer test-api-key"
assert captured["json"]["to"] == ["person@example.com"]
assert captured["timeout"] == 10.0
def test_resend_failure_does_not_log_sensitive_data(monkeypatch, caplog):
app = _email_app(MAIL_TRANSPORT="resend", RESEND_API_KEY="test-api-key")
def fail_post(*args, **kwargs):
raise requests.ConnectionError("provider unavailable")
monkeypatch.setattr(requests, "post", fail_post)
with app.app_context(), caplog.at_level(logging.INFO):
assert not send_email("private@example.com", "subject", "reset-secret")
assert "private@example.com" not in caplog.text
assert "reset-secret" not in caplog.text
assert "test-api-key" not in caplog.text
def _registry_client(tmp_path, monkeypatch):
db_path = tmp_path / "registry.db"
monkeypatch.setenv("CMDFORGE_REGISTRY_DB", str(db_path))
monkeypatch.setenv("CMDFORGE_ENV", "production")
monkeypatch.setenv("CMDFORGE_PUBLIC_URL", "https://cmdforge.example")
app = create_app()
app.config["TESTING"] = True
with connect_db() as db:
db.execute(
"""
INSERT INTO publishers (email, password_hash, slug, display_name)
VALUES (?, ?, ?, ?)
""",
["owner@example.com", password_hasher.hash("initial-password"), "owner", "Owner"],
)
db.commit()
return app.test_client(), db_path
def test_reset_uses_canonical_url_and_leaves_delivered_token_valid(tmp_path, monkeypatch):
client, db_path = _registry_client(tmp_path, monkeypatch)
captured = {}
def delivered(to, token, base_url):
captured.update(to=to, token=token, base_url=base_url)
return True
with patch("cmdforge.web.email.send_password_reset_email", delivered):
response = client.post(
"/api/v1/password-reset/request",
json={"email": "owner@example.com"},
headers={"Host": "attacker.example"},
)
assert response.status_code == 200
assert captured["base_url"] == "https://cmdforge.example"
with connect_db() as db:
row = db.execute(
"SELECT token_hash, used_at FROM password_reset_tokens"
).fetchone()
assert row["token_hash"] == hashlib.sha256(captured["token"].encode()).hexdigest()
assert row["used_at"] is None
def test_reset_invalidates_token_when_delivery_fails(tmp_path, monkeypatch):
client, db_path = _registry_client(tmp_path, monkeypatch)
with patch("cmdforge.web.email.send_password_reset_email", return_value=False):
response = client.post(
"/api/v1/password-reset/request", json={"email": "owner@example.com"}
)
assert response.status_code == 200
with connect_db() as db:
row = db.execute("SELECT used_at FROM password_reset_tokens").fetchone()
assert row["used_at"] is not None
def test_reset_invalidates_token_without_safe_production_base_url(tmp_path, monkeypatch):
client, db_path = _registry_client(tmp_path, monkeypatch)
client.application.config["PUBLIC_BASE_URL"] = "http://insecure.example"
with patch("cmdforge.web.email.send_password_reset_email") as sender:
response = client.post(
"/api/v1/password-reset/request", json={"email": "owner@example.com"}
)
assert response.status_code == 200
sender.assert_not_called()
with connect_db() as db:
row = db.execute("SELECT used_at FROM password_reset_tokens").fetchone()
assert row["used_at"] is not None
def test_unknown_email_creates_no_token_and_sends_nothing(tmp_path, monkeypatch):
client, db_path = _registry_client(tmp_path, monkeypatch)
with patch("cmdforge.web.email.send_password_reset_email") as sender:
response = client.post(
"/api/v1/password-reset/request", json={"email": "missing@example.com"}
)
assert response.status_code == 200
sender.assert_not_called()
with connect_db() as db:
count = db.execute("SELECT COUNT(*) FROM password_reset_tokens").fetchone()[0]
assert count == 0