From 84b06f78f1e0ce3f2455c78965c4170ed18ce163 Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Mon, 27 Jul 2026 00:35:53 +0300 Subject: [PATCH] =?UTF-8?q?test(email):=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BD=D1=8B=D0=B9=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81?= =?UTF-8?q?,=20Reply-To=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=B8=D1=81=D1=8C=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Покрытие валидации/сохранения contact_email, простановки Reply-To в письмах регистрации/приглашений/саммари при включённом и выключенном контактном адресе, и эндпоинта тестовой отправки (успех, дефолтный получатель, сбой транспорта без утечки логина/пароля). --- backend/tests/test_admin_api.py | 152 ++++++++++++++++++++++++ backend/tests/test_auth.py | 34 ++++++ backend/tests/test_instance_settings.py | 67 +++++++++++ backend/tests/test_invitations_task.py | 48 +++++++- backend/tests/test_notify_task.py | 46 ++++++- backend/tests/test_smtp_backend.py | 49 ++++++++ 6 files changed, 394 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py index df19764..ea0dd07 100644 --- a/backend/tests/test_admin_api.py +++ b/backend/tests/test_admin_api.py @@ -24,6 +24,7 @@ from models.team import Team from models.user import User from services import ai_levels from services.conference_ids import generate_number, generate_slug +from services.email import EmailSendError FUTURE = datetime.now(UTC) + timedelta(days=3) @@ -85,6 +86,7 @@ async def test_all_admin_endpoints_forbidden_for_non_admin( ("PATCH", f"/api/v1/admin/users/{other_user.id}", {"role": "admin"}), ("GET", "/api/v1/admin/settings", None), ("PUT", "/api/v1/admin/settings", {}), + ("POST", "/api/v1/admin/settings/test-email", {}), ] for method, path, body in requests: response = await client.request(method, path, json=body, headers=headers) @@ -606,3 +608,153 @@ async def test_put_settings_unavailable_ai_level_returns_400( headers=_auth_headers(admin), ) assert response.status_code == 400 + + +async def test_put_settings_contact_email_enable_and_persist( + client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False) + admin = await _make_user(db_session, role="admin") + await db_session.commit() + + response = await client.put( + "/api/v1/admin/settings", + json={"contact_email_enabled": True, "contact_email": "Contact@VidConf.RU"}, + headers=_auth_headers(admin), + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["contact_email_enabled"] is True + assert body["contact_email"] == "contact@vidconf.ru" + + reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin)) + assert reloaded.json()["contact_email"] == "contact@vidconf.ru" + + +async def test_put_settings_contact_email_invalid_returns_400( + client: httpx.AsyncClient, db_session: AsyncSession +) -> None: + admin = await _make_user(db_session, role="admin") + await db_session.commit() + + response = await client.put( + "/api/v1/admin/settings", + json={"contact_email_enabled": True, "contact_email": "not an email"}, + headers=_auth_headers(admin), + ) + assert response.status_code == 400 + + +# --- Тестовое письмо ---------------------------------------------------------------- + + +async def test_send_test_email_defaults_to_admin_email( + client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + """Без явного `to` тестовое письмо уходит на email текущего администратора.""" + admin = await _make_user(db_session, role="admin") + await db_session.commit() + + sent: dict[str, object] = {} + + class _FakeBackend: + async def send(self, **kwargs: object) -> None: + sent.update(kwargs) + + monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend()) + + response = await client.post( + "/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin) + ) + + assert response.status_code == 200, response.text + body = response.json() + assert body["success"] is True + assert sent["to"] == admin.email + + +async def test_send_test_email_to_explicit_recipient( + client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + admin = await _make_user(db_session, role="admin") + await db_session.commit() + + sent: dict[str, object] = {} + + class _FakeBackend: + async def send(self, **kwargs: object) -> None: + sent.update(kwargs) + + monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend()) + + response = await client.post( + "/api/v1/admin/settings/test-email", + json={"to": "other@example.com"}, + headers=_auth_headers(admin), + ) + + assert response.status_code == 200, response.text + assert sent["to"] == "other@example.com" + + +async def test_send_test_email_uses_contact_email_as_reply_to( + client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + admin = await _make_user(db_session, role="admin") + await db_session.commit() + await client.put( + "/api/v1/admin/settings", + json={"contact_email_enabled": True, "contact_email": "contact@vidconf.example"}, + headers=_auth_headers(admin), + ) + + sent: dict[str, object] = {} + + class _FakeBackend: + async def send(self, **kwargs: object) -> None: + sent.update(kwargs) + + monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FakeBackend()) + + response = await client.post( + "/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin) + ) + + assert response.status_code == 200, response.text + assert sent["reply_to"] == "contact@vidconf.example" + + +async def test_send_test_email_reports_transport_failure_without_leaking_secrets( + client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + """Сбой транспорта — понятный текст ошибки; хост/порт видны, логин/пароль — нет.""" + monkeypatch.setenv("EMAIL_BACKEND", "smtp") + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_PORT", "2525") + monkeypatch.setenv("SMTP_USERNAME", "secret-user") + monkeypatch.setenv("SMTP_PASSWORD", "super-secret-password") + get_settings.cache_clear() + + class _FailingBackend: + async def send(self, **kwargs: object) -> None: + raise EmailSendError("временный сбой SMTP: сервер недоступен", retryable=True) + + monkeypatch.setattr(admin_module, "create_email_backend", lambda settings: _FailingBackend()) + try: + admin = await _make_user(db_session, role="admin") + await db_session.commit() + + response = await client.post( + "/api/v1/admin/settings/test-email", json={}, headers=_auth_headers(admin) + ) + + assert response.status_code == 200, response.text + body = response.json() + assert body["success"] is False + assert "сбой SMTP" in body["message"] + assert body["smtp_host"] == "smtp.example.com" + assert body["smtp_port"] == 2525 + assert "secret-user" not in response.text + assert "super-secret-password" not in response.text + finally: + get_settings.cache_clear() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index f8079bc..1d98b49 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -48,6 +48,7 @@ class _CapturingEmailBackend: def __init__(self) -> None: self.sent: list[tuple[str, str, str]] = [] + self.reply_to: list[str | None] = [] async def send( self, @@ -57,8 +58,10 @@ class _CapturingEmailBackend: body: str, html_body: str | None = None, attachments: Sequence[EmailAttachment] = (), + reply_to: str | None = None, ) -> None: self.sent.append((to, subject, body)) + self.reply_to.append(reply_to) def _extract_verification_token(email_body: str) -> str: @@ -432,3 +435,34 @@ async def test_register_any_domain_allowed_when_verification_disabled( }, ) assert response.status_code == 201, response.text + + +async def test_register_sets_reply_to_when_contact_email_enabled( + client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend +) -> None: + """Контактный адрес инстанса включён — письмо подтверждения регистрации несёт `Reply-To`.""" + await InstanceSettingsService(db_session).update( + SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@vidconf.example") + ) + await db_session.commit() + + response = await client.post( + "/api/v1/auth/register", + json={"email": "dave@example.com", "name_user": "Dave", "password": "supersecret1"}, + ) + + assert response.status_code == 201, response.text + assert email_backend.reply_to[-1] == "contact@vidconf.example" + + +async def test_register_no_reply_to_when_contact_email_disabled( + client: httpx.AsyncClient, email_backend: _CapturingEmailBackend +) -> None: + """Контактный адрес выключен (дефолт) — `Reply-To` не проставляется.""" + response = await client.post( + "/api/v1/auth/register", + json={"email": "erin@example.com", "name_user": "Erin", "password": "supersecret1"}, + ) + + assert response.status_code == 201, response.text + assert email_backend.reply_to[-1] is None diff --git a/backend/tests/test_instance_settings.py b/backend/tests/test_instance_settings.py index 95388b8..90832fc 100644 --- a/backend/tests/test_instance_settings.py +++ b/backend/tests/test_instance_settings.py @@ -48,6 +48,7 @@ from services.instance_settings import ( BootstrapOverrides, InstanceSettingsService, InvalidAiLevelError, + InvalidContactEmailError, InvalidEmailDomainError, InvalidTimezoneError, SettingsUpdateIn, @@ -69,6 +70,7 @@ _MANAGED_KEYS = ( "display_timezone", "registration_team_choice", "registration_email_domain", + "contact_email", ) @@ -123,6 +125,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults( "display_timezone", "registration_team_choice", "registration_email_domain", + "contact_email", } cfg = await service.get() assert cfg.transcriber.provider == "faster_whisper_cpu" @@ -132,6 +135,8 @@ async def test_ensure_bootstrapped_imports_yaml_defaults( assert cfg.registration_team_choice is False assert cfg.registration_email_domain_enabled is False assert cfg.registration_email_domain is None + assert cfg.contact_email_enabled is False + assert cfg.contact_email is None async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits( @@ -384,6 +389,68 @@ async def test_registration_email_domain_can_be_disabled_keeping_stored_domain( assert cfg.registration_email_domain == "acme.io" +async def test_contact_email_enable_without_email_rejected( + db_session: AsyncSession, clean_instance_settings: None +) -> None: + """Включение контактного адреса без email (ни в патче, ни ранее сохранённого) → 400.""" + service = InstanceSettingsService(db_session) + await service.ensure_bootstrapped(PLUGINS_YAML) + + with pytest.raises(InvalidContactEmailError): + await service.update(SettingsUpdateIn(contact_email_enabled=True)) + + cfg = await service.get() + assert cfg.contact_email_enabled is False + assert cfg.contact_email is None + + +async def test_contact_email_rejects_invalid_format( + db_session: AsyncSession, clean_instance_settings: None +) -> None: + service = InstanceSettingsService(db_session) + await service.ensure_bootstrapped(PLUGINS_YAML) + + with pytest.raises(InvalidContactEmailError): + await service.update(SettingsUpdateIn(contact_email="not an email")) + + cfg = await service.get() + assert cfg.contact_email is None + + +async def test_contact_email_normalizes_input( + db_session: AsyncSession, clean_instance_settings: None +) -> None: + """` Contact@VidConf.RU ` нормализуется в `contact@vidconf.ru` (strip, lower).""" + service = InstanceSettingsService(db_session) + await service.ensure_bootstrapped(PLUGINS_YAML) + + cfg = await service.update( + SettingsUpdateIn(contact_email_enabled=True, contact_email=" Contact@VidConf.RU ") + ) + + assert cfg.contact_email_enabled is True + assert cfg.contact_email == "contact@vidconf.ru" + + reloaded = await service.get() + assert reloaded.contact_email == "contact@vidconf.ru" + + +async def test_contact_email_can_be_disabled_keeping_stored_email( + db_session: AsyncSession, clean_instance_settings: None +) -> None: + """Выключение контактного адреса без передачи email не требует его и не роняет валидацию.""" + service = InstanceSettingsService(db_session) + await service.ensure_bootstrapped(PLUGINS_YAML) + await service.update( + SettingsUpdateIn(contact_email_enabled=True, contact_email="contact@acme.io") + ) + + cfg = await service.update(SettingsUpdateIn(contact_email_enabled=False)) + + assert cfg.contact_email_enabled is False + assert cfg.contact_email == "contact@acme.io" + + async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarizer( db_session: AsyncSession, clean_instance_settings: None ) -> None: diff --git a/backend/tests/test_invitations_task.py b/backend/tests/test_invitations_task.py index 6336f99..8ebe201 100644 --- a/backend/tests/test_invitations_task.py +++ b/backend/tests/test_invitations_task.py @@ -19,6 +19,7 @@ from celery.exceptions import MaxRetriesExceededError from sqlalchemy import text from core.db import engine +from core.plugins.config import ChatConfig, InstanceConfig, SummarizerConfig, TranscriberConfig from services.conference_ids import generate_number, generate_slug from services.email import EmailAttachment, EmailSendError from workers.tasks import invitations as invitations_module @@ -65,10 +66,13 @@ class _FakeEmailBackend: body: str, html_body: str | None = None, attachments: tuple[EmailAttachment, ...] = (), + reply_to: str | None = None, ) -> None: if to in self._fail_for: raise EmailSendError(f"сбой отправки {to}", retryable=self._retryable) - self.sent.append({"to": to, "subject": subject, "attachments": attachments}) + self.sent.append( + {"to": to, "subject": subject, "attachments": attachments, "reply_to": reply_to} + ) class _Fixture: @@ -364,6 +368,48 @@ async def test_pinned_conference_sends_to_owner_and_past_participants_with_email assert delivered == sent_to +def _cfg( + *, contact_email_enabled: bool = False, contact_email: str | None = None +) -> InstanceConfig: + return InstanceConfig( + transcriber=TranscriberConfig(), + summarizer=SummarizerConfig(), + chat=ChatConfig(), + contact_email_enabled=contact_email_enabled, + contact_email=contact_email, + ) + + +async def test_invitations_set_reply_to_when_contact_email_enabled( + fx: _Fixture, monkeypatch: pytest.MonkeyPatch +) -> None: + """Включённый контактный адрес инстанса — приглашение несёт `Reply-To`.""" + mock_backend = _FakeEmailBackend() + monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) + + await send_invitations_async( + _FakeTask(), + fx.conference_id, + plugins_config=_cfg(contact_email_enabled=True, contact_email="contact@vidconf.example"), + ) + + assert mock_backend.sent + assert all(call["reply_to"] == "contact@vidconf.example" for call in mock_backend.sent) + + +async def test_invitations_no_reply_to_when_contact_email_disabled( + fx: _Fixture, monkeypatch: pytest.MonkeyPatch +) -> None: + """Контактный адрес выключен (дефолт) — `Reply-To` не проставляется.""" + mock_backend = _FakeEmailBackend() + monkeypatch.setattr(invitations_module, "create_email_backend", lambda settings: mock_backend) + + await send_invitations_async(_FakeTask(), fx.conference_id, plugins_config=_cfg()) + + assert mock_backend.sent + assert all(call["reply_to"] is None for call in mock_backend.sent) + + async def test_explicit_emails_override_default_recipients( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/backend/tests/test_notify_task.py b/backend/tests/test_notify_task.py index 745b3ed..38ede6e 100644 --- a/backend/tests/test_notify_task.py +++ b/backend/tests/test_notify_task.py @@ -60,6 +60,7 @@ class _FakeEmailBackend: def __init__(self, *, fail_for: set[str] | None = None, retryable: bool = True) -> None: self.sent: list[str] = [] + self.reply_to: list[str | None] = [] self._fail_for = fail_for or set() self._retryable = retryable @@ -71,10 +72,12 @@ class _FakeEmailBackend: body: str, html_body: str | None = None, attachments: Any = (), + reply_to: str | None = None, ) -> None: if to in self._fail_for: raise EmailSendError(f"сбой отправки для {to}", retryable=self._retryable) self.sent.append(to) + self.reply_to.append(reply_to) class _Fixture: @@ -221,12 +224,19 @@ async def fx() -> AsyncGenerator[_Fixture, None]: await conn.commit() -def _cfg(*, summary_recipients: str = "all") -> InstanceConfig: +def _cfg( + *, + summary_recipients: str = "all", + contact_email_enabled: bool = False, + contact_email: str | None = None, +) -> InstanceConfig: return InstanceConfig( transcriber=TranscriberConfig(), summarizer=SummarizerConfig(), chat=ChatConfig(), summary_recipients=cast("Any", summary_recipients), + contact_email_enabled=contact_email_enabled, + contact_email=contact_email, ) @@ -291,6 +301,40 @@ async def test_notify_session_mode_owner_sends_only_to_owner( assert await _fetch_session_status(fx.session_id) == "notified" +async def test_notify_session_sets_reply_to_when_contact_email_enabled( + fx: _Fixture, monkeypatch: pytest.MonkeyPatch +) -> None: + """Включённый контактный адрес инстанса — саммари-письмо несёт `Reply-To`.""" + backend = _FakeEmailBackend() + monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend) + + await notify_session_async( + _FakeTask(), + fx.session_id, + plugins_config=_cfg( + summary_recipients="owner", + contact_email_enabled=True, + contact_email="contact@vidconf.example", + ), + ) + + assert backend.reply_to == ["contact@vidconf.example"] + + +async def test_notify_session_no_reply_to_when_contact_email_disabled( + fx: _Fixture, monkeypatch: pytest.MonkeyPatch +) -> None: + """Контактный адрес выключен (дефолт) — `Reply-To` не проставляется.""" + backend = _FakeEmailBackend() + monkeypatch.setattr(notify_module, "create_email_backend", lambda settings: backend) + + await notify_session_async( + _FakeTask(), fx.session_id, plugins_config=_cfg(summary_recipients="owner") + ) + + assert backend.reply_to == [None] + + async def test_notify_session_conference_override_wins_over_instance_default( fx: _Fixture, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/backend/tests/test_smtp_backend.py b/backend/tests/test_smtp_backend.py index caf8857..12a202c 100644 --- a/backend/tests/test_smtp_backend.py +++ b/backend/tests/test_smtp_backend.py @@ -7,6 +7,8 @@ """ import logging +from email.message import EmailMessage +from typing import cast import aiosmtplib import pytest @@ -90,6 +92,33 @@ def test_build_message_includes_html_alternative_and_ics_attachment() -> None: assert attachments[0].get_payload(decode=True) == b"BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n" +def test_build_message_sets_reply_to_when_given() -> None: + message = _build_message( + sender="VidConf ", + to="user@example.com", + subject="Тема", + body="Тело", + html_body=None, + attachments=(), + reply_to="contact@vidconf.example", + ) + + assert message["Reply-To"] == "contact@vidconf.example" + + +def test_build_message_omits_reply_to_when_not_given() -> None: + message = _build_message( + sender="VidConf ", + to="user@example.com", + subject="Тема", + body="Тело", + html_body=None, + attachments=(), + ) + + assert message["Reply-To"] is None + + def _backend() -> SmtpEmailBackend: return SmtpEmailBackend( hostname="smtp.example.com", @@ -168,3 +197,23 @@ async def test_smtp_backend_send_success_calls_aiosmtplib(monkeypatch: pytest.Mo "use_tls": False, "timeout": 30, } + + +async def test_smtp_backend_send_passes_reply_to_into_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + async def _fake_send(message: object, **kwargs: object) -> tuple[dict[str, object], str]: + captured["message"] = message + return {}, "OK" + + monkeypatch.setattr(aiosmtplib, "send", _fake_send) + backend = _backend() + + await backend.send( + to="user@example.com", subject="Тема", body="Тело", reply_to="contact@vidconf.example" + ) + + message = cast(EmailMessage, captured["message"]) + assert message["Reply-To"] == "contact@vidconf.example"