test_password_reset_template_email.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. """The password-reset link email is built from the "Password Reset" template.
  2. The template was seeded and editable, but since the reset became a link email
  3. no code read it: the email was always the built-in one, whatever the admin
  4. wrote. Its default still described the old flow (a new password emailed in
  5. plain text). The email now renders the template, and the code adds the link at
  6. the end. The link is not a template variable, so the template's text cannot
  7. change it.
  8. """
  9. from __future__ import annotations
  10. from unittest.mock import AsyncMock, patch
  11. import pytest
  12. from sqlalchemy import text
  13. from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
  14. from backend.app.core import database as _db
  15. from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
  16. from backend.app.schemas.notification_template import EVENT_VARIABLES, SAMPLE_DATA
  17. from backend.app.services.email_service import create_password_reset_link_email_from_template
  18. RESET_URL = "https://bambuddy.example.com/login#reset_token=abc123"
  19. DEFAULT = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "password_reset")
  20. def _template(title: str, body: str) -> NotificationTemplate:
  21. return NotificationTemplate(
  22. event_type="password_reset", name="Password Reset", title_template=title, body_template=body, is_default=False
  23. )
  24. async def _render(template: NotificationTemplate | None):
  25. with patch("backend.app.services.email_service.get_notification_template", return_value=template):
  26. return await create_password_reset_link_email_from_template(AsyncMock(spec=AsyncSession), "maker", RESET_URL)
  27. async def test_the_default_template_sends_the_link():
  28. subject, text_body, html_body = await _render(_template(DEFAULT["title_template"], DEFAULT["body_template"]))
  29. assert subject == "BamBuddy - Password Reset"
  30. assert "maker" in text_body
  31. assert text_body.endswith(f"\n\n{RESET_URL}")
  32. assert text_body.count(RESET_URL) == 1
  33. assert f'href="{RESET_URL}"' in html_body
  34. assert "{" not in text_body
  35. async def test_an_edited_template_is_what_the_user_gets_with_the_link_at_the_end():
  36. subject, text_body, html_body = await _render(
  37. _template("Reset for {username}", "Hi {username}\nThe {app_name} team")
  38. )
  39. assert subject == "Reset for maker"
  40. assert text_body == f"Hi maker\nThe BamBuddy team\n\n{RESET_URL}"
  41. assert "<br>" in html_body
  42. async def test_a_template_cannot_place_the_link():
  43. """A {reset_url} in the template renders as nothing; the link is only the
  44. one the code adds."""
  45. subject, text_body, html_body = await _render(
  46. _template("Reset {reset_url}", "Reset here: https://elsewhere.example/r?next={reset_url}")
  47. )
  48. assert text_body == f"Reset here: https://elsewhere.example/r?next=\n\n{RESET_URL}"
  49. assert "reset_token" not in subject
  50. assert "next=https" not in text_body and "next=https" not in html_body
  51. assert html_body.count("reset_token") == 2 # the line at the end and the button
  52. async def test_a_template_for_the_old_flow_still_carries_the_link():
  53. """Customised for the old flow: {password} is gone (renders empty)."""
  54. _subject, text_body, html_body = await _render(
  55. _template("Reset", "Hello {username},\nNew Password: {password}\nLogin at: {login_url}")
  56. )
  57. assert text_body.endswith(f"\n\n{RESET_URL}")
  58. assert f'href="{RESET_URL}"' in html_body
  59. async def test_the_body_is_escaped_in_the_html():
  60. _subject, _text, html_body = await _render(_template("Reset", "<b>{username}</b>"))
  61. assert "<b>maker</b>" not in html_body
  62. assert "&lt;b&gt;maker&lt;/b&gt;" in html_body
  63. async def test_without_a_template_the_built_in_email_is_sent():
  64. subject, text_body, html_body = await _render(None)
  65. assert subject == "BamBuddy - Password Reset Request"
  66. assert RESET_URL in text_body and RESET_URL in html_body
  67. def test_the_editor_offers_neither_the_link_nor_a_password():
  68. for gone in ("reset_url", "password", "login_url"):
  69. assert gone not in EVENT_VARIABLES["password_reset"]
  70. assert gone not in SAMPLE_DATA["password_reset"]
  71. def test_the_default_template_has_no_link_and_no_password():
  72. assert "{reset_url}" not in DEFAULT["body_template"]
  73. assert "{password}" not in DEFAULT["body_template"]
  74. # --- upgrade: the shipped default is rewritten, an edited one is kept ---
  75. @pytest.fixture
  76. async def engine():
  77. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  78. async with engine.begin() as conn:
  79. await conn.run_sync(NotificationTemplate.__table__.create)
  80. try:
  81. yield engine
  82. finally:
  83. await engine.dispose()
  84. async def _insert(conn, body: str) -> None:
  85. await conn.execute(
  86. text(
  87. "INSERT INTO notification_templates (event_type, name, title_template, body_template, is_default) "
  88. "VALUES ('password_reset', 'Password Reset', '{app_name} - Password Reset', :b, 1)"
  89. ),
  90. {"b": body},
  91. )
  92. async def _body(conn) -> str:
  93. return (
  94. await conn.execute(text("SELECT body_template FROM notification_templates WHERE event_type = 'password_reset'"))
  95. ).scalar_one()
  96. async def test_the_upgrade_rewrites_the_old_default(engine):
  97. old, new = _db._PASSWORD_RESET_TEMPLATE_BODY
  98. async with engine.begin() as conn:
  99. await _insert(conn, old)
  100. for _ in range(2): # runs on every start
  101. async with engine.begin() as conn:
  102. await _db._migrate_password_reset_template_link(conn)
  103. async with engine.begin() as conn:
  104. assert await _body(conn) == new
  105. async def test_the_upgrade_keeps_an_edited_template(engine):
  106. async with engine.begin() as conn:
  107. await _insert(conn, "My own words {password}")
  108. async with engine.begin() as conn:
  109. await _db._migrate_password_reset_template_link(conn)
  110. assert await _body(conn) == "My own words {password}"
  111. def test_the_upgrade_gives_what_a_fresh_install_gets():
  112. assert _db._PASSWORD_RESET_TEMPLATE_BODY[1] == DEFAULT["body_template"]
  113. async def test_start_up_runs_the_upgrade(tmp_path):
  114. from sqlalchemy.ext.asyncio import AsyncSession as Session
  115. from backend.app.core.database import Base, run_migrations
  116. from backend.app.main import app # noqa: F401 registers every model, as init_db does
  117. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'old.db'}")
  118. try:
  119. async with engine.begin() as conn:
  120. await conn.run_sync(Base.metadata.create_all)
  121. await _insert(conn, _db._PASSWORD_RESET_TEMPLATE_BODY[0])
  122. async with engine.begin() as conn:
  123. await run_migrations(conn)
  124. async with Session(engine) as session:
  125. body = (await session.execute(text("SELECT body_template FROM notification_templates"))).scalar_one()
  126. assert body == _db._PASSWORD_RESET_TEMPLATE_BODY[1]
  127. finally:
  128. await engine.dispose()