test_welcome_template_password.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. """A new user's initial password never passes through the editable Welcome template.
  2. The Welcome Email template (user_created) rendered {password} as a variable.
  3. Bambuddy now adds the login details itself, at the end of the text and in the
  4. HTML, so the template's text cannot change them; a {password} in the template
  5. renders as nothing.
  6. """
  7. from __future__ import annotations
  8. import re
  9. from unittest.mock import AsyncMock, patch
  10. import pytest
  11. from sqlalchemy import text
  12. from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
  13. from backend.app.core import database as _db
  14. from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
  15. from backend.app.schemas.notification_template import EVENT_VARIABLES, SAMPLE_DATA
  16. from backend.app.services.email_service import create_welcome_email_from_template
  17. PASSWORD = "S3cr3t&<pw>"
  18. LOGIN_URL = "https://bambuddy.example.com/login"
  19. DEFAULT = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "user_created")
  20. DETAILS = f"Username: maker\nPassword: {PASSWORD}"
  21. def _template(title: str, body: str) -> NotificationTemplate:
  22. return NotificationTemplate(
  23. event_type="user_created", name="Welcome Email", 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_welcome_email_from_template(AsyncMock(spec=AsyncSession), "maker", PASSWORD, LOGIN_URL)
  28. async def test_a_template_cannot_carry_the_password_off():
  29. subject, text_body, html_body = await _render(
  30. _template("Hi {password}", "Activate: https://elsewhere.example/a?p={password}")
  31. )
  32. assert PASSWORD not in subject
  33. assert text_body == f"Activate: https://elsewhere.example/a?p=\n\n{DETAILS}"
  34. assert "p=S3cr3t" not in html_body
  35. async def test_the_default_template_sends_the_details_once():
  36. subject, text_body, html_body = await _render(_template(DEFAULT["title_template"], DEFAULT["body_template"]))
  37. assert subject == "Welcome to BamBuddy"
  38. assert text_body.endswith(f"\n\n{DETAILS}")
  39. assert text_body.count(PASSWORD) == 1
  40. assert LOGIN_URL in text_body
  41. assert "{" not in text_body
  42. assert f'href="{LOGIN_URL}"' in html_body
  43. async def test_the_details_are_escaped_in_the_html():
  44. _subject, _text, html_body = await _render(_template("Welcome", "Hello"))
  45. assert html_body.count("S3cr3t&amp;&lt;pw&gt;") == 1 # in the box only
  46. assert PASSWORD not in html_body
  47. async def test_an_edited_template_keeps_its_words():
  48. _subject, text_body, _html = await _render(_template("Welcome", "Hallo {username}, viel Spass mit {app_name}!"))
  49. assert text_body == f"Hallo maker, viel Spass mit BamBuddy!\n\n{DETAILS}"
  50. async def test_the_timestamp_the_editor_offers_is_filled():
  51. _subject, text_body, _html = await _render(_template("Welcome", "Created {timestamp}"))
  52. assert re.match(r"Created \d{4}-\d{2}-\d{2} \d{2}:\d{2}\n\n", text_body)
  53. async def test_without_a_template_the_built_in_email_is_sent():
  54. subject, text_body, _html = await _render(None)
  55. assert subject == "Welcome to BamBuddy - Your Account Details"
  56. assert PASSWORD in text_body
  57. def test_the_editor_does_not_offer_the_password():
  58. assert "password" not in EVENT_VARIABLES["user_created"]
  59. assert "password" not in SAMPLE_DATA["user_created"]
  60. def test_the_default_template_has_no_password():
  61. assert "{password}" not in DEFAULT["body_template"]
  62. assert "{password}" not in DEFAULT["title_template"]
  63. # --- upgrade: the shipped default is rewritten, an edited one is kept ---
  64. @pytest.fixture
  65. async def engine():
  66. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  67. async with engine.begin() as conn:
  68. await conn.run_sync(NotificationTemplate.__table__.create)
  69. try:
  70. yield engine
  71. finally:
  72. await engine.dispose()
  73. async def _insert(conn, body: str) -> None:
  74. await conn.execute(
  75. text(
  76. "INSERT INTO notification_templates (event_type, name, title_template, body_template, is_default) "
  77. "VALUES ('user_created', 'Welcome Email', 'Welcome to {app_name}', :b, 1)"
  78. ),
  79. {"b": body},
  80. )
  81. async def _body(conn) -> str:
  82. return (
  83. await conn.execute(text("SELECT body_template FROM notification_templates WHERE event_type = 'user_created'"))
  84. ).scalar_one()
  85. async def test_the_upgrade_rewrites_the_old_default(engine):
  86. old, new = _db._WELCOME_TEMPLATE_BODY
  87. async with engine.begin() as conn:
  88. await _insert(conn, old)
  89. for _ in range(2): # runs on every start
  90. async with engine.begin() as conn:
  91. await _db._migrate_welcome_template_without_password(conn)
  92. async with engine.begin() as conn:
  93. assert await _body(conn) == new
  94. async def test_the_upgrade_keeps_an_edited_template(engine):
  95. async with engine.begin() as conn:
  96. await _insert(conn, "My own words {password}")
  97. async with engine.begin() as conn:
  98. await _db._migrate_welcome_template_without_password(conn)
  99. assert await _body(conn) == "My own words {password}"
  100. def test_the_upgrade_gives_what_a_fresh_install_gets():
  101. assert _db._WELCOME_TEMPLATE_BODY[1] == DEFAULT["body_template"]
  102. @pytest.mark.sqlite_only # upgrades its own SQLite database
  103. async def test_start_up_runs_the_upgrade(tmp_path):
  104. from backend.app.core.database import Base, run_migrations
  105. from backend.app.main import app # noqa: F401 registers every model, as init_db does
  106. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'old.db'}")
  107. try:
  108. async with engine.begin() as conn:
  109. await conn.run_sync(Base.metadata.create_all)
  110. await _insert(conn, _db._WELCOME_TEMPLATE_BODY[0])
  111. async with engine.begin() as conn:
  112. await run_migrations(conn)
  113. async with engine.connect() as conn:
  114. assert await _body(conn) == _db._WELCOME_TEMPLATE_BODY[1]
  115. finally:
  116. await engine.dispose()