test_confirm_token_retirement_migration_1898.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. """Migration coverage for the one-tap token columns, index and prompt body (#1898).
  2. ``confirm_token_used_at`` is what turns a spent link into "already answered"
  3. instead of a 404, and ``user_verdict_source`` is what the hint next to the
  4. verdict badge reads — both have to reach an install that upgraded rather than
  5. one created fresh from the models. So does the index on ``confirm_token``: the
  6. route that reads it runs with no authentication, so without the index anyone
  7. who can reach the host turns a stream of invented tokens into a stream of full
  8. scans of print_archives.
  9. """
  10. import pytest
  11. from sqlalchemy import text
  12. from sqlalchemy.ext.asyncio import create_async_engine
  13. import backend.app.models # noqa: F401 - populate Base.metadata
  14. import backend.app.models.external_link # noqa: F401 - required by a legacy ALTER in run_migrations
  15. import backend.app.models.print_log # noqa: F401 - required by a legacy ALTER in run_migrations
  16. import backend.app.models.virtual_printer # noqa: F401 - required by a legacy ALTER in run_migrations
  17. from backend.app.core.database import Base, run_migrations
  18. @pytest.fixture(autouse=True)
  19. def force_sqlite_dialect(monkeypatch):
  20. """run_migrations branches on the global dialect, not on the connection, so
  21. a dev config pointing at Postgres would run Postgres-only syntax against the
  22. SQLite engine below. Same fixture as test_billing_run_id_migration.py."""
  23. from backend.app.core import db_dialect
  24. monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
  25. monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
  26. # database.py imported is_sqlite at module load time — patch there too.
  27. from backend.app.core import database as database_module
  28. monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
  29. async def _archive_columns(conn) -> set[str]:
  30. return {row[1] for row in (await conn.execute(text("PRAGMA table_info(print_archives)"))).all()}
  31. @pytest.mark.asyncio
  32. async def test_retirement_columns_are_added_and_migration_is_idempotent(tmp_path):
  33. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-retirement.db'}")
  34. try:
  35. async with engine.begin() as conn:
  36. await conn.run_sync(Base.metadata.create_all)
  37. # Simulate the pre-#1898-follow-up schema: the columns only exist
  38. # here because create_all built the table from today's models.
  39. await conn.execute(text("ALTER TABLE print_archives DROP COLUMN confirm_token_used_at"))
  40. await conn.execute(text("ALTER TABLE print_archives DROP COLUMN user_verdict_source"))
  41. assert "confirm_token_used_at" not in await _archive_columns(conn)
  42. await run_migrations(conn)
  43. columns = await _archive_columns(conn)
  44. assert "confirm_token_used_at" in columns
  45. assert "user_verdict_source" in columns
  46. # Re-running the migrations on an already-migrated install is a
  47. # no-op, not an error: _safe_execute swallows the duplicate ALTER.
  48. await run_migrations(conn)
  49. assert await _archive_columns(conn) >= {"confirm_token_used_at", "user_verdict_source"}
  50. # ...with the SQLite-flavoured types the ALTERs declare, so a
  51. # stamp round-trips as a datetime rather than as opaque text.
  52. types = {
  53. row[1]: row[2].upper() for row in (await conn.execute(text("PRAGMA table_info(print_archives)"))).all()
  54. }
  55. assert types["confirm_token_used_at"] == "DATETIME"
  56. assert types["user_verdict_source"].startswith("VARCHAR")
  57. finally:
  58. await engine.dispose()
  59. async def _archive_indexes(conn) -> dict[str, bool]:
  60. """Index name -> whether it is UNIQUE, for print_archives."""
  61. rows = (await conn.execute(text("PRAGMA index_list(print_archives)"))).all()
  62. return {row[1]: bool(row[2]) for row in rows}
  63. def test_the_model_declares_the_index():
  64. """A fresh install gets its schema from the models, not from
  65. run_migrations, so the declaration is half the fix."""
  66. from backend.app.models.archive import PrintArchive
  67. column = PrintArchive.__table__.c.confirm_token
  68. assert column.index is True
  69. assert column.unique is True
  70. @pytest.mark.asyncio
  71. async def test_confirm_requested_is_nullable_on_both_kinds_of_install(tmp_path):
  72. """A fresh database and an upgraded one must describe the column the same way.
  73. ``ALTER TABLE ... ADD COLUMN confirm_requested BOOLEAN DEFAULT FALSE`` cannot
  74. carry NOT NULL, so an upgraded install has a nullable column. The model has
  75. to agree, or every install created from it has a stricter table than every
  76. install that grew into it -- and the difference only ever shows up as an
  77. IntegrityError on somebody else's machine.
  78. """
  79. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-nullable.db'}")
  80. try:
  81. async with engine.begin() as conn:
  82. await conn.run_sync(Base.metadata.create_all)
  83. def notnull(rows):
  84. return {row[1]: row[3] for row in rows}
  85. fresh = notnull((await conn.execute(text("PRAGMA table_info(print_archives)"))).all())
  86. assert fresh["confirm_requested"] == 0
  87. # ...and the column an upgrade adds, for comparison.
  88. await conn.execute(text("ALTER TABLE print_archives DROP COLUMN confirm_requested"))
  89. await run_migrations(conn)
  90. upgraded = notnull((await conn.execute(text("PRAGMA table_info(print_archives)"))).all())
  91. assert upgraded["confirm_requested"] == fresh["confirm_requested"]
  92. finally:
  93. await engine.dispose()
  94. @pytest.mark.asyncio
  95. async def test_the_confirm_token_index_reaches_an_upgraded_install(tmp_path):
  96. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-token-index.db'}")
  97. try:
  98. async with engine.begin() as conn:
  99. await conn.run_sync(Base.metadata.create_all)
  100. # An install that upgraded from before the index: the column is
  101. # there, the index is not.
  102. await conn.execute(text("DROP INDEX ix_print_archives_confirm_token"))
  103. assert "ix_print_archives_confirm_token" not in await _archive_indexes(conn)
  104. await run_migrations(conn)
  105. indexes = await _archive_indexes(conn)
  106. assert "ix_print_archives_confirm_token" in indexes
  107. assert indexes["ix_print_archives_confirm_token"] is True, "must be UNIQUE"
  108. # Re-running is a no-op, not an error.
  109. await run_migrations(conn)
  110. assert "ix_print_archives_confirm_token" in await _archive_indexes(conn)
  111. # Most archives never get a token, so the unique index has to
  112. # tolerate any number of NULLs — otherwise the second archive on a
  113. # fresh install would fail to insert.
  114. from backend.app.models.archive import PrintArchive
  115. rows = [
  116. {"filename": "a.3mf", "file_path": "a", "file_size": 1, "status": "completed"},
  117. {"filename": "b.3mf", "file_path": "b", "file_size": 1, "status": "completed"},
  118. ]
  119. await conn.execute(PrintArchive.__table__.insert(), rows)
  120. stored = (await conn.execute(text("SELECT confirm_token FROM print_archives"))).scalars().all()
  121. assert stored == [None, None]
  122. finally:
  123. await engine.dispose()
  124. OLD_CONFIRM_BODY = "{printer}: {filename}\nGood: {good_url}\nReject: {reject_url}"
  125. async def _confirm_body(conn) -> str | None:
  126. return (
  127. await conn.execute(
  128. text("SELECT body_template FROM notification_templates WHERE event_type = 'print_confirm_request'")
  129. )
  130. ).scalar_one_or_none()
  131. @pytest.mark.asyncio
  132. async def test_the_capability_urls_leave_the_prompt_body_on_an_upgraded_install(tmp_path):
  133. """Seeding only inserts templates that are missing, so an install already
  134. running this feature would keep the shape the review found: both single-use
  135. verdict URLs as plain text in every channel's message body, where the
  136. unfurler that answers the prompt reads them."""
  137. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-body.db'}")
  138. try:
  139. async with engine.begin() as conn:
  140. await conn.run_sync(Base.metadata.create_all)
  141. await conn.execute(
  142. text(
  143. "INSERT INTO notification_templates (event_type, name, title_template, body_template, is_default) "
  144. "VALUES ('print_confirm_request', 'Print Outcome Confirmation', "
  145. "'How did your print come out?', :body, 1)"
  146. ),
  147. {"body": OLD_CONFIRM_BODY},
  148. )
  149. await run_migrations(conn)
  150. body = await _confirm_body(conn)
  151. assert body is not None
  152. assert "{good_url}" not in body
  153. assert "{reject_url}" not in body
  154. assert "{confirm_url}" in body
  155. # Idempotent, and it agrees with what a fresh install seeds.
  156. await run_migrations(conn)
  157. from backend.app.models.notification_template import DEFAULT_TEMPLATES
  158. seeded = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "print_confirm_request")
  159. assert await _confirm_body(conn) == seeded["body_template"]
  160. finally:
  161. await engine.dispose()
  162. @pytest.mark.asyncio
  163. async def test_a_template_the_admin_edited_is_left_alone(tmp_path):
  164. """Same guard as the two template renames: match the old default verbatim
  165. or do not touch the row."""
  166. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-body-custom.db'}")
  167. custom = "Plate off {printer}? {good_url}"
  168. try:
  169. async with engine.begin() as conn:
  170. await conn.run_sync(Base.metadata.create_all)
  171. await conn.execute(
  172. text(
  173. "INSERT INTO notification_templates (event_type, name, title_template, body_template, is_default) "
  174. "VALUES ('print_confirm_request', 'Mine', 'Well?', :body, 0)"
  175. ),
  176. {"body": custom},
  177. )
  178. await run_migrations(conn)
  179. assert await _confirm_body(conn) == custom
  180. finally:
  181. await engine.dispose()