| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226 |
- """Migration coverage for the one-tap token columns, index and prompt body (#1898).
- ``confirm_token_used_at`` is what turns a spent link into "already answered"
- instead of a 404, and ``user_verdict_source`` is what the hint next to the
- verdict badge reads — both have to reach an install that upgraded rather than
- one created fresh from the models. So does the index on ``confirm_token``: the
- route that reads it runs with no authentication, so without the index anyone
- who can reach the host turns a stream of invented tokens into a stream of full
- scans of print_archives.
- """
- import pytest
- from sqlalchemy import text
- from sqlalchemy.ext.asyncio import create_async_engine
- import backend.app.models # noqa: F401 - populate Base.metadata
- import backend.app.models.external_link # noqa: F401 - required by a legacy ALTER in run_migrations
- import backend.app.models.print_log # noqa: F401 - required by a legacy ALTER in run_migrations
- import backend.app.models.virtual_printer # noqa: F401 - required by a legacy ALTER in run_migrations
- from backend.app.core.database import Base, run_migrations
- @pytest.fixture(autouse=True)
- def force_sqlite_dialect(monkeypatch):
- """run_migrations branches on the global dialect, not on the connection, so
- a dev config pointing at Postgres would run Postgres-only syntax against the
- SQLite engine below. Same fixture as test_billing_run_id_migration.py."""
- from backend.app.core import db_dialect
- monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
- monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
- # database.py imported is_sqlite at module load time — patch there too.
- from backend.app.core import database as database_module
- monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
- async def _archive_columns(conn) -> set[str]:
- return {row[1] for row in (await conn.execute(text("PRAGMA table_info(print_archives)"))).all()}
- @pytest.mark.asyncio
- async def test_retirement_columns_are_added_and_migration_is_idempotent(tmp_path):
- engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-retirement.db'}")
- try:
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- # Simulate the pre-#1898-follow-up schema: the columns only exist
- # here because create_all built the table from today's models.
- await conn.execute(text("ALTER TABLE print_archives DROP COLUMN confirm_token_used_at"))
- await conn.execute(text("ALTER TABLE print_archives DROP COLUMN user_verdict_source"))
- assert "confirm_token_used_at" not in await _archive_columns(conn)
- await run_migrations(conn)
- columns = await _archive_columns(conn)
- assert "confirm_token_used_at" in columns
- assert "user_verdict_source" in columns
- # Re-running the migrations on an already-migrated install is a
- # no-op, not an error: _safe_execute swallows the duplicate ALTER.
- await run_migrations(conn)
- assert await _archive_columns(conn) >= {"confirm_token_used_at", "user_verdict_source"}
- # ...with the SQLite-flavoured types the ALTERs declare, so a
- # stamp round-trips as a datetime rather than as opaque text.
- types = {
- row[1]: row[2].upper() for row in (await conn.execute(text("PRAGMA table_info(print_archives)"))).all()
- }
- assert types["confirm_token_used_at"] == "DATETIME"
- assert types["user_verdict_source"].startswith("VARCHAR")
- finally:
- await engine.dispose()
- async def _archive_indexes(conn) -> dict[str, bool]:
- """Index name -> whether it is UNIQUE, for print_archives."""
- rows = (await conn.execute(text("PRAGMA index_list(print_archives)"))).all()
- return {row[1]: bool(row[2]) for row in rows}
- def test_the_model_declares_the_index():
- """A fresh install gets its schema from the models, not from
- run_migrations, so the declaration is half the fix."""
- from backend.app.models.archive import PrintArchive
- column = PrintArchive.__table__.c.confirm_token
- assert column.index is True
- assert column.unique is True
- @pytest.mark.asyncio
- async def test_confirm_requested_is_nullable_on_both_kinds_of_install(tmp_path):
- """A fresh database and an upgraded one must describe the column the same way.
- ``ALTER TABLE ... ADD COLUMN confirm_requested BOOLEAN DEFAULT FALSE`` cannot
- carry NOT NULL, so an upgraded install has a nullable column. The model has
- to agree, or every install created from it has a stricter table than every
- install that grew into it -- and the difference only ever shows up as an
- IntegrityError on somebody else's machine.
- """
- engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-nullable.db'}")
- try:
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- def notnull(rows):
- return {row[1]: row[3] for row in rows}
- fresh = notnull((await conn.execute(text("PRAGMA table_info(print_archives)"))).all())
- assert fresh["confirm_requested"] == 0
- # ...and the column an upgrade adds, for comparison.
- await conn.execute(text("ALTER TABLE print_archives DROP COLUMN confirm_requested"))
- await run_migrations(conn)
- upgraded = notnull((await conn.execute(text("PRAGMA table_info(print_archives)"))).all())
- assert upgraded["confirm_requested"] == fresh["confirm_requested"]
- finally:
- await engine.dispose()
- @pytest.mark.asyncio
- async def test_the_confirm_token_index_reaches_an_upgraded_install(tmp_path):
- engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-token-index.db'}")
- try:
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- # An install that upgraded from before the index: the column is
- # there, the index is not.
- await conn.execute(text("DROP INDEX ix_print_archives_confirm_token"))
- assert "ix_print_archives_confirm_token" not in await _archive_indexes(conn)
- await run_migrations(conn)
- indexes = await _archive_indexes(conn)
- assert "ix_print_archives_confirm_token" in indexes
- assert indexes["ix_print_archives_confirm_token"] is True, "must be UNIQUE"
- # Re-running is a no-op, not an error.
- await run_migrations(conn)
- assert "ix_print_archives_confirm_token" in await _archive_indexes(conn)
- # Most archives never get a token, so the unique index has to
- # tolerate any number of NULLs — otherwise the second archive on a
- # fresh install would fail to insert.
- from backend.app.models.archive import PrintArchive
- rows = [
- {"filename": "a.3mf", "file_path": "a", "file_size": 1, "status": "completed"},
- {"filename": "b.3mf", "file_path": "b", "file_size": 1, "status": "completed"},
- ]
- await conn.execute(PrintArchive.__table__.insert(), rows)
- stored = (await conn.execute(text("SELECT confirm_token FROM print_archives"))).scalars().all()
- assert stored == [None, None]
- finally:
- await engine.dispose()
- OLD_CONFIRM_BODY = "{printer}: {filename}\nGood: {good_url}\nReject: {reject_url}"
- async def _confirm_body(conn) -> str | None:
- return (
- await conn.execute(
- text("SELECT body_template FROM notification_templates WHERE event_type = 'print_confirm_request'")
- )
- ).scalar_one_or_none()
- @pytest.mark.asyncio
- async def test_the_capability_urls_leave_the_prompt_body_on_an_upgraded_install(tmp_path):
- """Seeding only inserts templates that are missing, so an install already
- running this feature would keep the shape the review found: both single-use
- verdict URLs as plain text in every channel's message body, where the
- unfurler that answers the prompt reads them."""
- engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-body.db'}")
- try:
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- await conn.execute(
- text(
- "INSERT INTO notification_templates (event_type, name, title_template, body_template, is_default) "
- "VALUES ('print_confirm_request', 'Print Outcome Confirmation', "
- "'How did your print come out?', :body, 1)"
- ),
- {"body": OLD_CONFIRM_BODY},
- )
- await run_migrations(conn)
- body = await _confirm_body(conn)
- assert body is not None
- assert "{good_url}" not in body
- assert "{reject_url}" not in body
- assert "{confirm_url}" in body
- # Idempotent, and it agrees with what a fresh install seeds.
- await run_migrations(conn)
- from backend.app.models.notification_template import DEFAULT_TEMPLATES
- seeded = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "print_confirm_request")
- assert await _confirm_body(conn) == seeded["body_template"]
- finally:
- await engine.dispose()
- @pytest.mark.asyncio
- async def test_a_template_the_admin_edited_is_left_alone(tmp_path):
- """Same guard as the two template renames: match the old default verbatim
- or do not touch the row."""
- engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'confirm-body-custom.db'}")
- custom = "Plate off {printer}? {good_url}"
- try:
- async with engine.begin() as conn:
- await conn.run_sync(Base.metadata.create_all)
- await conn.execute(
- text(
- "INSERT INTO notification_templates (event_type, name, title_template, body_template, is_default) "
- "VALUES ('print_confirm_request', 'Mine', 'Well?', :body, 0)"
- ),
- {"body": custom},
- )
- await run_migrations(conn)
- assert await _confirm_body(conn) == custom
- finally:
- await engine.dispose()
|