"""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 ", "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", "

body

", "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