test_password_reset_template_email.py 6.9 KB

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