diff --git a/app/api/routes/api/config.py b/app/api/routes/api/config.py index c5cc215..ee91333 100644 --- a/app/api/routes/api/config.py +++ b/app/api/routes/api/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from app.api.routes.api.deps import require_csrf, require_session @@ -14,9 +15,11 @@ from app.schemas.config import ( ConfigSection, ConfigUpdateRequest, ConfigUpdateResponse, + SmtpTestResponse, ) from app.services.auth import AuthenticatedSession 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__) @@ -69,3 +72,48 @@ def put_config( refreshed_settings = get_settings() sections_raw = build_config_sections(db, refreshed_settings) 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."}, + ) diff --git a/app/schemas/config.py b/app/schemas/config.py index f2176c4..eab94b1 100644 --- a/app/schemas/config.py +++ b/app/schemas/config.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Literal + from pydantic import BaseModel @@ -29,3 +31,10 @@ class ConfigUpdateRequest(BaseModel): class ConfigUpdateResponse(BaseModel): sections: list[ConfigSection] + + +class SmtpTestResponse(BaseModel): + """Response from POST /api/config/smtp/test.""" + + result: Literal["success", "config-error", "failed"] + message: str diff --git a/openapi/openapi.json b/openapi/openapi.json index 518953c..43b605c 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -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": { "get": { "tags": [ @@ -1749,6 +1819,30 @@ ], "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": { "properties": { "status": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index e9bda66..381b09b 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -222,6 +222,61 @@ paths: application/json: schema: $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: get: tags: @@ -1192,6 +1247,24 @@ components: - username - force_password_change 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: properties: status: diff --git a/tests/test_api_config.py b/tests/test_api_config.py index 9a74d5f..20d4006 100644 --- a/tests/test_api_config.py +++ b/tests/test_api_config.py @@ -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