M2-T05: add SMTP test action API (POST /api/config/smtp/test)
- reuses send_smtp_test_email; tri-state result success(200)/config-error(400)/failed(502) - session + CSRF protected; never echoes SMTP secrets - SmtpTestResponse schema; regenerate openapi/ - extend tests/test_api_config.py (3 states + 401 + missing-CSRF 403)
This commit is contained in:
+109
-1
@@ -1,13 +1,16 @@
|
||||
"""Tests for M2-T01: GET /api/config and PUT /api/config."""
|
||||
"""Tests for M2-T01: GET /api/config and PUT /api/config.
|
||||
Tests for M2-T05: POST /api/config/smtp/test."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services.config_page import CONFIG_FIELDS
|
||||
from app.services.email import EmailConfigurationError, EmailDeliveryError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -316,3 +319,108 @@ def test_put_config_response_does_not_leak_secret_values(client: TestClient) ->
|
||||
|
||||
# The secret value itself should not appear anywhere in the raw response
|
||||
assert "super-secret-token" not in str(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/smtp/test — M2-T05
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SMTP_TEST_URL = "/api/config/smtp/test"
|
||||
_SMTP_SEND_PATH = "app.api.routes.api.config.send_smtp_test_email"
|
||||
|
||||
|
||||
def test_smtp_test_unauthenticated_returns_401(client: TestClient) -> None:
|
||||
"""Unauthenticated request must return 401 (require_session fires before require_csrf)."""
|
||||
response = client.post(
|
||||
_SMTP_TEST_URL,
|
||||
headers={"X-CSRF-Token": "any-token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_smtp_test_authenticated_missing_csrf_returns_403(client: TestClient) -> None:
|
||||
"""Authenticated but no X-CSRF-Token header must return 403."""
|
||||
_login(client)
|
||||
response = client.post(_SMTP_TEST_URL)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_smtp_test_authenticated_empty_csrf_returns_403(client: TestClient) -> None:
|
||||
"""Authenticated but empty X-CSRF-Token header must return 403."""
|
||||
_login(client)
|
||||
response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": ""})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_smtp_test_success_returns_200(client: TestClient) -> None:
|
||||
"""When send_smtp_test_email succeeds (returns None), endpoint returns 200 with result=success."""
|
||||
_login(client)
|
||||
|
||||
with patch(_SMTP_SEND_PATH, return_value=None) as mock_send:
|
||||
response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": "any-token"})
|
||||
|
||||
mock_send.assert_called_once()
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["result"] == "success"
|
||||
assert "message" in body
|
||||
|
||||
|
||||
def test_smtp_test_config_error_returns_400(client: TestClient) -> None:
|
||||
"""When send_smtp_test_email raises EmailConfigurationError, endpoint returns 400 with result=config-error."""
|
||||
_login(client)
|
||||
|
||||
with patch(_SMTP_SEND_PATH, side_effect=EmailConfigurationError("SMTP host is required")):
|
||||
response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": "any-token"})
|
||||
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert body["result"] == "config-error"
|
||||
assert "message" in body
|
||||
assert "SMTP host is required" in body["message"]
|
||||
|
||||
|
||||
def test_smtp_test_delivery_error_returns_502(client: TestClient) -> None:
|
||||
"""When send_smtp_test_email raises EmailDeliveryError, endpoint returns 502 with result=failed."""
|
||||
_login(client)
|
||||
|
||||
with patch(_SMTP_SEND_PATH, side_effect=EmailDeliveryError("connection refused")):
|
||||
response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": "any-token"})
|
||||
|
||||
assert response.status_code == 502
|
||||
body = response.json()
|
||||
assert body["result"] == "failed"
|
||||
assert "message" in body
|
||||
assert "connection refused" in body["message"]
|
||||
|
||||
|
||||
def test_smtp_test_response_does_not_echo_smtp_password(client: TestClient) -> None:
|
||||
"""The SMTP password stored in config must not appear in any API response body."""
|
||||
_login(client)
|
||||
|
||||
smtp_password = "s3cr3t-smtp-pass"
|
||||
|
||||
# Store a fake SMTP password in config
|
||||
payload = _full_config_payload({"SMTP_PASSWORD": smtp_password})
|
||||
client.put(
|
||||
"/api/config",
|
||||
json={"updates": payload},
|
||||
headers={"X-CSRF-Token": "token"},
|
||||
)
|
||||
|
||||
# Simulate a delivery error whose message has already been sanitised by the
|
||||
# service layer (i.e. the password does not appear in the exception text).
|
||||
# This mirrors production behaviour: email.py's _sanitize_error_message
|
||||
# replaces any password occurrence with "[redacted]" before raising.
|
||||
with patch(
|
||||
_SMTP_SEND_PATH,
|
||||
side_effect=EmailDeliveryError("authentication failure: [redacted]"),
|
||||
):
|
||||
response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": "token"})
|
||||
|
||||
assert response.status_code == 502
|
||||
body = response.json()
|
||||
assert "result" in body
|
||||
assert "message" in body
|
||||
# The plaintext password must not appear anywhere in the response body
|
||||
assert smtp_password not in response.text
|
||||
|
||||
Reference in New Issue
Block a user