| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- """Unit tests for email service.
- These tests verify email template rendering, HTML formatting,
- password generation, and SMTP settings persistence.
- """
- import string
- from unittest.mock import AsyncMock, patch
- import pytest
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.models.notification_template import NotificationTemplate
- from backend.app.services.email_service import (
- create_password_reset_email_from_template,
- create_welcome_email_from_template,
- generate_secure_password,
- render_template,
- )
- class TestEmailTemplateFormatting:
- """Tests for email template formatting."""
- @pytest.mark.asyncio
- async def test_welcome_email_newlines_converted_to_br(self):
- """Verify that newlines in welcome email body are converted to <br> tags."""
- # Mock database session
- db = AsyncMock(spec=AsyncSession)
- # Mock template with newlines
- template = NotificationTemplate(
- event_type="user_created",
- name="Welcome Email",
- title_template="Welcome to {app_name}",
- body_template="Hello {username}!\n\nYour password is: {password}\n\nPlease login at: {login_url}",
- is_default=True,
- )
- # Patch get_notification_template to return our template
- with patch("backend.app.services.email_service.get_notification_template", return_value=template):
- # Generate email
- subject, text_body, html_body = await create_welcome_email_from_template(
- db=db,
- username="testuser",
- password="testpass123",
- login_url="http://example.com/login",
- app_name="TestApp",
- )
- # Verify subject
- assert subject == "Welcome to TestApp"
- # Verify text body has newlines
- assert "\n\n" in text_body
- assert "Hello testuser!" in text_body
- assert "Your password is: testpass123" in text_body
- # Verify HTML body has <br> tags instead of relying on CSS
- assert "<br>" in html_body
- # Should not use white-space: pre-wrap
- assert "white-space: pre-wrap" not in html_body
- # Should have proper structure
- assert "<!DOCTYPE html>" in html_body
- assert '<div style="font-size: 16px;">' in html_body
- # Verify that escaped content is present (XSS protection)
- assert "Hello testuser!<br>" in html_body
- assert "Your password is: testpass123<br>" in html_body
- @pytest.mark.asyncio
- async def test_password_reset_email_newlines_converted_to_br(self):
- """Verify that newlines in password reset email body are converted to <br> tags."""
- # Mock database session
- db = AsyncMock(spec=AsyncSession)
- # Mock template with newlines
- template = NotificationTemplate(
- event_type="password_reset",
- name="Password Reset",
- title_template="{app_name} - Password Reset",
- body_template="Hello {username},\n\nYour password has been reset.\nNew password: {password}\n\nLogin at: {login_url}",
- is_default=True,
- )
- # Patch get_notification_template to return our template
- with patch("backend.app.services.email_service.get_notification_template", return_value=template):
- # Generate email
- subject, text_body, html_body = await create_password_reset_email_from_template(
- db=db,
- username="testuser",
- password="newpass456",
- login_url="http://example.com/login",
- app_name="TestApp",
- )
- # Verify subject
- assert subject == "TestApp - Password Reset"
- # Verify text body has newlines
- assert "\n\n" in text_body
- assert "Hello testuser," in text_body
- # Verify HTML body has <br> tags
- assert "<br>" in html_body
- # Should not use white-space: pre-wrap
- assert "white-space: pre-wrap" not in html_body
- # Should have security alert
- assert "Security Alert" in html_body
- @pytest.mark.asyncio
- async def test_email_header_padding(self):
- """Verify that email header has proper padding to prevent cutoff."""
- # Mock database session
- db = AsyncMock(spec=AsyncSession)
- # Mock template
- template = NotificationTemplate(
- event_type="user_created",
- name="Welcome Email",
- title_template="Welcome",
- body_template="Test body",
- is_default=True,
- )
- # Patch get_notification_template to return our template
- with patch("backend.app.services.email_service.get_notification_template", return_value=template):
- # Generate email
- subject, text_body, html_body = await create_welcome_email_from_template(
- db=db,
- username="testuser",
- password="testpass123",
- login_url="http://example.com/login",
- )
- # Verify header has 30px padding (not 20px which was cutting off)
- assert "padding: 30px; border-radius: 8px 8px 0 0;" in html_body
- @pytest.mark.asyncio
- async def test_email_xss_protection(self):
- """Verify that HTML escaping is applied to prevent XSS attacks."""
- # Mock database session
- db = AsyncMock(spec=AsyncSession)
- # Mock template with potential XSS content
- template = NotificationTemplate(
- event_type="user_created",
- name="Welcome Email",
- title_template="Welcome <script>alert('xss')</script>",
- body_template="Hello <script>alert('xss')</script>\nTest",
- is_default=True,
- )
- # Patch get_notification_template to return our template
- with patch("backend.app.services.email_service.get_notification_template", return_value=template):
- # Generate email
- subject, text_body, html_body = await create_welcome_email_from_template(
- db=db,
- username="testuser",
- password="testpass123",
- login_url="http://example.com/login",
- )
- # Verify that script tags are escaped
- assert "<script>" in html_body
- # Verify no unescaped script tags
- assert "<script>" not in html_body
- class TestGenerateSecurePassword:
- """Tests for generate_secure_password()."""
- def test_password_default_length(self):
- """Default password is 16 characters."""
- password = generate_secure_password()
- assert len(password) == 16
- def test_password_custom_length(self):
- """Custom length is respected."""
- password = generate_secure_password(24)
- assert len(password) == 24
- def test_password_has_required_char_types(self):
- """Password contains uppercase, lowercase, digit, and special character."""
- # Run multiple times to reduce flakiness from random shuffling
- for _ in range(5):
- password = generate_secure_password()
- assert any(c in string.ascii_uppercase for c in password), "Missing uppercase"
- assert any(c in string.ascii_lowercase for c in password), "Missing lowercase"
- assert any(c in string.digits for c in password), "Missing digit"
- assert any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password), "Missing special"
- class TestRenderTemplate:
- """Tests for render_template()."""
- def test_render_template_basic(self):
- """Placeholders are replaced correctly."""
- result = render_template("Hello {name}, welcome to {app}!", {"name": "Alice", "app": "BamBuddy"})
- assert result == "Hello Alice, welcome to BamBuddy!"
- def test_render_template_removes_unreplaced(self):
- """Unreplaced placeholders are removed."""
- result = render_template("Hello {name}, your code is {code}", {"name": "Bob"})
- assert result == "Hello Bob, your code is "
- class TestSMTPSettingsPersistence:
- """Tests for save_smtp_settings() and get_smtp_settings() round-trip."""
- @pytest.mark.asyncio
- async def test_save_and_retrieve_smtp_settings(self, db_session):
- """Save SMTP settings, then retrieve them and verify values match."""
- from backend.app.schemas.auth import SMTPSettings
- from backend.app.services.email_service import get_smtp_settings, save_smtp_settings
- settings = SMTPSettings(
- smtp_host="mail.example.com",
- smtp_port=465,
- smtp_username="user@example.com",
- smtp_password="secret",
- smtp_security="ssl",
- smtp_auth_enabled=True,
- smtp_from_email="noreply@example.com",
- )
- await save_smtp_settings(db_session, settings)
- await db_session.commit()
- retrieved = await get_smtp_settings(db_session)
- assert retrieved is not None
- assert retrieved.smtp_host == "mail.example.com"
- assert retrieved.smtp_port == 465
- assert retrieved.smtp_username == "user@example.com"
- assert retrieved.smtp_password == "secret"
- assert retrieved.smtp_security == "ssl"
- assert retrieved.smtp_auth_enabled is True
- assert retrieved.smtp_from_email == "noreply@example.com"
- @pytest.mark.asyncio
- async def test_get_smtp_settings_returns_none_when_unconfigured(self, db_session):
- """Empty DB returns None for SMTP settings."""
- from backend.app.services.email_service import get_smtp_settings
- result = await get_smtp_settings(db_session)
- assert result is None
|