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:
2026-06-12 23:41:03 +02:00
parent 3ec663e138
commit 2bc5d6ea9a
5 changed files with 333 additions and 1 deletions
+48
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session from app.api.routes.api.deps import require_csrf, require_session
@@ -14,9 +15,11 @@ from app.schemas.config import (
ConfigSection, ConfigSection,
ConfigUpdateRequest, ConfigUpdateRequest,
ConfigUpdateResponse, ConfigUpdateResponse,
SmtpTestResponse,
) )
from app.services.auth import AuthenticatedSession from app.services.auth import AuthenticatedSession
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_smtp_test_email
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -69,3 +72,48 @@ def put_config(
refreshed_settings = get_settings() refreshed_settings = get_settings()
sections_raw = build_config_sections(db, refreshed_settings) sections_raw = build_config_sections(db, refreshed_settings)
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw)) return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
@router.post(
"/config/smtp/test",
responses={
200: {"model": SmtpTestResponse},
400: {"model": SmtpTestResponse},
502: {"model": SmtpTestResponse},
},
)
def post_smtp_test(
settings: Settings = Depends(get_app_settings),
_auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> JSONResponse:
"""
Send a test SMTP email using the current runtime settings.
Returns a structured result indicating success or the category of failure.
Three possible outcomes:
- 200 { "result": "success", "message": ... }
- 400 { "result": "config-error", "message": ... } (EmailConfigurationError)
- 502 { "result": "failed", "message": ... } (EmailDeliveryError)
SMTP credentials are never echoed in the response.
"""
try:
send_smtp_test_email(settings)
except EmailConfigurationError as exc:
logger.warning("SMTP test rejected due to configuration: %s", exc)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"result": "config-error", "message": str(exc)},
)
except EmailDeliveryError as exc:
logger.warning("SMTP test delivery failed: %s", exc)
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content={"result": "failed", "message": str(exc)},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={"result": "success", "message": "Test email sent successfully."},
)
+9
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal
from pydantic import BaseModel from pydantic import BaseModel
@@ -29,3 +31,10 @@ class ConfigUpdateRequest(BaseModel):
class ConfigUpdateResponse(BaseModel): class ConfigUpdateResponse(BaseModel):
sections: list[ConfigSection] sections: list[ConfigSection]
class SmtpTestResponse(BaseModel):
"""Response from POST /api/config/smtp/test."""
result: Literal["success", "config-error", "failed"]
message: str
+94
View File
@@ -350,6 +350,76 @@
} }
} }
}, },
"/api/config/smtp/test": {
"post": {
"tags": [
"api-config"
],
"summary": "Post Smtp Test",
"description": "Send a test SMTP email using the current runtime settings.\n\nReturns a structured result indicating success or the category of failure.\nThree possible outcomes:\n- 200 { \"result\": \"success\", \"message\": ... }\n- 400 { \"result\": \"config-error\", \"message\": ... } (EmailConfigurationError)\n- 502 { \"result\": \"failed\", \"message\": ... } (EmailDeliveryError)\n\nSMTP credentials are never echoed in the response.",
"operationId": "post_smtp_test_api_config_smtp_test_post",
"parameters": [
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SmtpTestResponse"
}
}
}
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SmtpTestResponse"
}
}
},
"description": "Bad Request"
},
"502": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SmtpTestResponse"
}
}
},
"description": "Bad Gateway"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/locations": { "/api/locations": {
"get": { "get": {
"tags": [ "tags": [
@@ -1749,6 +1819,30 @@
], ],
"title": "SessionUser" "title": "SessionUser"
}, },
"SmtpTestResponse": {
"properties": {
"result": {
"type": "string",
"enum": [
"success",
"config-error",
"failed"
],
"title": "Result"
},
"message": {
"type": "string",
"title": "Message"
}
},
"type": "object",
"required": [
"result",
"message"
],
"title": "SmtpTestResponse",
"description": "Response from POST /api/config/smtp/test."
},
"StatusResponse": { "StatusResponse": {
"properties": { "properties": {
"status": { "status": {
+73
View File
@@ -222,6 +222,61 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $ref: '#/components/schemas/HTTPValidationError'
/api/config/smtp/test:
post:
tags:
- api-config
summary: Post Smtp Test
description: 'Send a test SMTP email using the current runtime settings.
Returns a structured result indicating success or the category of failure.
Three possible outcomes:
- 200 { "result": "success", "message": ... }
- 400 { "result": "config-error", "message": ... } (EmailConfigurationError)
- 502 { "result": "failed", "message": ... } (EmailDeliveryError)
SMTP credentials are never echoed in the response.'
operationId: post_smtp_test_api_config_smtp_test_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/SmtpTestResponse'
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/SmtpTestResponse'
description: Bad Request
'502':
content:
application/json:
schema:
$ref: '#/components/schemas/SmtpTestResponse'
description: Bad Gateway
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/locations: /api/locations:
get: get:
tags: tags:
@@ -1192,6 +1247,24 @@ components:
- username - username
- force_password_change - force_password_change
title: SessionUser title: SessionUser
SmtpTestResponse:
properties:
result:
type: string
enum:
- success
- config-error
- failed
title: Result
message:
type: string
title: Message
type: object
required:
- result
- message
title: SmtpTestResponse
description: Response from POST /api/config/smtp/test.
StatusResponse: StatusResponse:
properties: properties:
status: status:
+109 -1
View File
@@ -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 from __future__ import annotations
import re import re
import sqlite3 import sqlite3
from unittest.mock import patch
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.config import get_settings from app.config import get_settings
from app.services.config_page import CONFIG_FIELDS 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 # The secret value itself should not appear anywhere in the raw response
assert "super-secret-token" not in str(body) 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