test_email_service.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. """Unit tests for email service.
  2. These tests verify email template rendering, HTML formatting,
  3. password generation, and SMTP settings persistence.
  4. """
  5. import string
  6. from unittest.mock import AsyncMock, patch
  7. import pytest
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from backend.app.models.notification_template import NotificationTemplate
  10. from backend.app.services.email_service import (
  11. create_password_reset_email_from_template,
  12. create_welcome_email_from_template,
  13. generate_secure_password,
  14. render_template,
  15. )
  16. class TestEmailTemplateFormatting:
  17. """Tests for email template formatting."""
  18. @pytest.mark.asyncio
  19. async def test_welcome_email_newlines_converted_to_br(self):
  20. """Verify that newlines in welcome email body are converted to <br> tags."""
  21. # Mock database session
  22. db = AsyncMock(spec=AsyncSession)
  23. # Mock template with newlines
  24. template = NotificationTemplate(
  25. event_type="user_created",
  26. name="Welcome Email",
  27. title_template="Welcome to {app_name}",
  28. body_template="Hello {username}!\n\nYour password is: {password}\n\nPlease login at: {login_url}",
  29. is_default=True,
  30. )
  31. # Patch get_notification_template to return our template
  32. with patch("backend.app.services.email_service.get_notification_template", return_value=template):
  33. # Generate email
  34. subject, text_body, html_body = await create_welcome_email_from_template(
  35. db=db,
  36. username="testuser",
  37. password="testpass123",
  38. login_url="http://example.com/login",
  39. app_name="TestApp",
  40. )
  41. # Verify subject
  42. assert subject == "Welcome to TestApp"
  43. # Verify text body has newlines
  44. assert "\n\n" in text_body
  45. assert "Hello testuser!" in text_body
  46. assert "Your password is: testpass123" in text_body
  47. # Verify HTML body has <br> tags instead of relying on CSS
  48. assert "<br>" in html_body
  49. # Should not use white-space: pre-wrap
  50. assert "white-space: pre-wrap" not in html_body
  51. # Should have proper structure
  52. assert "<!DOCTYPE html>" in html_body
  53. assert '<div style="font-size: 16px;">' in html_body
  54. # Verify that escaped content is present (XSS protection)
  55. assert "Hello testuser!<br>" in html_body
  56. assert "Your password is: testpass123<br>" in html_body
  57. @pytest.mark.asyncio
  58. async def test_password_reset_email_newlines_converted_to_br(self):
  59. """Verify that newlines in password reset email body are converted to <br> tags."""
  60. # Mock database session
  61. db = AsyncMock(spec=AsyncSession)
  62. # Mock template with newlines
  63. template = NotificationTemplate(
  64. event_type="password_reset",
  65. name="Password Reset",
  66. title_template="{app_name} - Password Reset",
  67. body_template="Hello {username},\n\nYour password has been reset.\nNew password: {password}\n\nLogin at: {login_url}",
  68. is_default=True,
  69. )
  70. # Patch get_notification_template to return our template
  71. with patch("backend.app.services.email_service.get_notification_template", return_value=template):
  72. # Generate email
  73. subject, text_body, html_body = await create_password_reset_email_from_template(
  74. db=db,
  75. username="testuser",
  76. password="newpass456",
  77. login_url="http://example.com/login",
  78. app_name="TestApp",
  79. )
  80. # Verify subject
  81. assert subject == "TestApp - Password Reset"
  82. # Verify text body has newlines
  83. assert "\n\n" in text_body
  84. assert "Hello testuser," in text_body
  85. # Verify HTML body has <br> tags
  86. assert "<br>" in html_body
  87. # Should not use white-space: pre-wrap
  88. assert "white-space: pre-wrap" not in html_body
  89. # Should have security alert
  90. assert "Security Alert" in html_body
  91. @pytest.mark.asyncio
  92. async def test_email_header_padding(self):
  93. """Verify that email header has proper padding to prevent cutoff."""
  94. # Mock database session
  95. db = AsyncMock(spec=AsyncSession)
  96. # Mock template
  97. template = NotificationTemplate(
  98. event_type="user_created",
  99. name="Welcome Email",
  100. title_template="Welcome",
  101. body_template="Test body",
  102. is_default=True,
  103. )
  104. # Patch get_notification_template to return our template
  105. with patch("backend.app.services.email_service.get_notification_template", return_value=template):
  106. # Generate email
  107. subject, text_body, html_body = await create_welcome_email_from_template(
  108. db=db,
  109. username="testuser",
  110. password="testpass123",
  111. login_url="http://example.com/login",
  112. )
  113. # Verify header has 30px padding (not 20px which was cutting off)
  114. assert "padding: 30px; border-radius: 8px 8px 0 0;" in html_body
  115. @pytest.mark.asyncio
  116. async def test_email_xss_protection(self):
  117. """Verify that HTML escaping is applied to prevent XSS attacks."""
  118. # Mock database session
  119. db = AsyncMock(spec=AsyncSession)
  120. # Mock template with potential XSS content
  121. template = NotificationTemplate(
  122. event_type="user_created",
  123. name="Welcome Email",
  124. title_template="Welcome <script>alert('xss')</script>",
  125. body_template="Hello <script>alert('xss')</script>\nTest",
  126. is_default=True,
  127. )
  128. # Patch get_notification_template to return our template
  129. with patch("backend.app.services.email_service.get_notification_template", return_value=template):
  130. # Generate email
  131. subject, text_body, html_body = await create_welcome_email_from_template(
  132. db=db,
  133. username="testuser",
  134. password="testpass123",
  135. login_url="http://example.com/login",
  136. )
  137. # Verify that script tags are escaped
  138. assert "&lt;script&gt;" in html_body
  139. # Verify no unescaped script tags
  140. assert "<script>" not in html_body
  141. class TestGenerateSecurePassword:
  142. """Tests for generate_secure_password()."""
  143. def test_password_default_length(self):
  144. """Default password is 16 characters."""
  145. password = generate_secure_password()
  146. assert len(password) == 16
  147. def test_password_custom_length(self):
  148. """Custom length is respected."""
  149. password = generate_secure_password(24)
  150. assert len(password) == 24
  151. def test_password_has_required_char_types(self):
  152. """Password contains uppercase, lowercase, digit, and special character."""
  153. # Run multiple times to reduce flakiness from random shuffling
  154. for _ in range(5):
  155. password = generate_secure_password()
  156. assert any(c in string.ascii_uppercase for c in password), "Missing uppercase"
  157. assert any(c in string.ascii_lowercase for c in password), "Missing lowercase"
  158. assert any(c in string.digits for c in password), "Missing digit"
  159. assert any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password), "Missing special"
  160. class TestRenderTemplate:
  161. """Tests for render_template()."""
  162. def test_render_template_basic(self):
  163. """Placeholders are replaced correctly."""
  164. result = render_template("Hello {name}, welcome to {app}!", {"name": "Alice", "app": "BamBuddy"})
  165. assert result == "Hello Alice, welcome to BamBuddy!"
  166. def test_render_template_removes_unreplaced(self):
  167. """Unreplaced placeholders are removed."""
  168. result = render_template("Hello {name}, your code is {code}", {"name": "Bob"})
  169. assert result == "Hello Bob, your code is "
  170. class TestSMTPSettingsPersistence:
  171. """Tests for save_smtp_settings() and get_smtp_settings() round-trip."""
  172. @pytest.mark.asyncio
  173. async def test_save_and_retrieve_smtp_settings(self, db_session):
  174. """Save SMTP settings, then retrieve them and verify values match."""
  175. from backend.app.schemas.auth import SMTPSettings
  176. from backend.app.services.email_service import get_smtp_settings, save_smtp_settings
  177. settings = SMTPSettings(
  178. smtp_host="mail.example.com",
  179. smtp_port=465,
  180. smtp_username="user@example.com",
  181. smtp_password="secret",
  182. smtp_security="ssl",
  183. smtp_auth_enabled=True,
  184. smtp_from_email="noreply@example.com",
  185. )
  186. await save_smtp_settings(db_session, settings)
  187. await db_session.commit()
  188. retrieved = await get_smtp_settings(db_session)
  189. assert retrieved is not None
  190. assert retrieved.smtp_host == "mail.example.com"
  191. assert retrieved.smtp_port == 465
  192. assert retrieved.smtp_username == "user@example.com"
  193. assert retrieved.smtp_password == "secret"
  194. assert retrieved.smtp_security == "ssl"
  195. assert retrieved.smtp_auth_enabled is True
  196. assert retrieved.smtp_from_email == "noreply@example.com"
  197. @pytest.mark.asyncio
  198. async def test_get_smtp_settings_returns_none_when_unconfigured(self, db_session):
  199. """Empty DB returns None for SMTP settings."""
  200. from backend.app.services.email_service import get_smtp_settings
  201. result = await get_smtp_settings(db_session)
  202. assert result is None