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:
@@ -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."},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user