fix(migrations): enforce schema ownership safety

This commit is contained in:
caoxiaozhu
2026-07-14 09:23:34 +08:00
parent 1347366b95
commit 11275e4ba6
18 changed files with 755 additions and 57 deletions

View File

@@ -0,0 +1,135 @@
from __future__ import annotations
from pathlib import Path
import pytest
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, text
from sqlalchemy.engine import Engine
from app.db.migration_preflight import (
MIGRATION_OWNED_TABLES_BY_REVISION,
MigrationPreflightError,
validate_migration_state,
)
@pytest.fixture
def engine() -> Engine:
database = create_engine("sqlite+pysqlite:///:memory:")
try:
yield database
finally:
database.dispose()
def _create_tables(engine: Engine, table_names: set[str] | frozenset[str]) -> None:
metadata = MetaData()
for table_name in table_names:
Table(table_name, metadata, Column("id", Integer, primary_key=True))
metadata.create_all(engine)
def _create_version_table(engine: Engine, *revisions: str) -> None:
with engine.begin() as connection:
connection.execute(text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)"))
for revision in revisions:
connection.execute(
text("INSERT INTO alembic_version (version_num) VALUES (:revision)"),
{"revision": revision},
)
def test_unversioned_database_without_migration_owned_tables_is_safe(engine: Engine) -> None:
state = validate_migration_state(engine)
assert state.revision is None
assert state.owned_tables == frozenset()
@pytest.mark.parametrize(
"owned_table",
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260713_0002"]),
)
def test_unversioned_database_with_any_migration_owned_table_is_rejected(
engine: Engine,
owned_table: str,
) -> None:
_create_tables(engine, {owned_table})
with pytest.raises(MigrationPreflightError, match="unversioned database contains"):
validate_migration_state(engine)
@pytest.mark.parametrize(
("revision", "expected_tables"),
list(MIGRATION_OWNED_TABLES_BY_REVISION.items()),
)
def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
engine: Engine,
revision: str,
expected_tables: frozenset[str],
) -> None:
_create_tables(engine, expected_tables)
_create_version_table(engine, revision)
state = validate_migration_state(engine)
assert state.revision == revision
assert state.owned_tables == expected_tables
@pytest.mark.parametrize(
("revision", "actual_tables"),
[
("20260713_0001", frozenset({"expense_cases", "expense_case_links"})),
("20260713_0001", MIGRATION_OWNED_TABLES_BY_REVISION["20260713_0002"]),
(
"20260713_0002",
MIGRATION_OWNED_TABLES_BY_REVISION["20260713_0002"] - {"auth_sessions"},
),
],
)
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(
engine: Engine,
revision: str,
actual_tables: frozenset[str],
) -> None:
_create_tables(engine, actual_tables)
_create_version_table(engine, revision)
with pytest.raises(MigrationPreflightError, match="does not match revision"):
validate_migration_state(engine)
def test_unknown_revision_is_rejected(engine: Engine) -> None:
_create_version_table(engine, "20990101_unknown")
with pytest.raises(MigrationPreflightError, match="unknown Alembic revision"):
validate_migration_state(engine)
def test_multiple_revisions_are_rejected(engine: Engine) -> None:
_create_version_table(engine, "20260713_0001", "20260713_0002")
with pytest.raises(MigrationPreflightError, match="multiple Alembic revisions"):
validate_migration_state(engine)
def test_empty_version_table_is_safe_only_when_owned_tables_are_absent(engine: Engine) -> None:
_create_version_table(engine)
assert validate_migration_state(engine).revision is None
_create_tables(engine, {"expense_cases"})
with pytest.raises(MigrationPreflightError, match="no recorded revision"):
validate_migration_state(engine)
def test_server_start_runs_preflight_before_alembic_upgrade() -> None:
script_path = Path(__file__).resolve().parents[1] / "server_start.sh"
script = script_path.read_text(encoding="utf-8")
preflight = '"$PYTHON_BIN" -m app.db.migration_preflight'
upgrade = '"$PYTHON_BIN" -m alembic -c "$SCRIPT_DIR/alembic.ini" upgrade head'
assert 'PYTHONPATH="$SCRIPT_DIR/src${PYTHONPATH:+:$PYTHONPATH}"' in script
assert script.index(preflight) < script.index(upgrade)