fix(migrations): enforce schema ownership safety
This commit is contained in:
364
server/tests/test_alembic_migrations.py
Normal file
364
server/tests/test_alembic_migrations.py
Normal file
@@ -0,0 +1,364 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.engine import Engine, make_url
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from alembic import command
|
||||
from app.core.config import get_settings
|
||||
from app.db.migration_preflight import MigrationPreflightError, validate_migration_state
|
||||
from app.db.schema_ownership import MIGRATION_OWNED_TABLES
|
||||
|
||||
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
|
||||
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
|
||||
HEAD_REVISION = "20260713_0002"
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
|
||||
|
||||
def _normalize_probe_component(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
|
||||
|
||||
def _is_disposable_probe_host(value: str) -> bool:
|
||||
markers = ("migration-probe", "disposable-probe")
|
||||
return value in markers or any(
|
||||
value.startswith(f"{marker}-") or value.startswith(f"x-financial-{marker}-")
|
||||
for marker in markers
|
||||
)
|
||||
|
||||
|
||||
def _is_disposable_probe_database(value: str) -> bool:
|
||||
markers = ("migration-probe", "disposable-probe")
|
||||
return value in markers or any(value.startswith(f"{marker}-") for marker in markers)
|
||||
|
||||
|
||||
def _require_disposable_probe_url(raw_url: str) -> str:
|
||||
try:
|
||||
parsed = make_url(raw_url)
|
||||
except Exception as exc: # pragma: no cover - SQLAlchemy 提供具体解析异常
|
||||
raise RuntimeError("MIGRATION_TEST_DATABASE_URL 不是有效的数据库 URL") from exc
|
||||
|
||||
if parsed.get_backend_name() != "postgresql":
|
||||
raise RuntimeError("迁移测试只允许连接 PostgreSQL 一次性数据库")
|
||||
|
||||
host = _normalize_probe_component(parsed.host or "")
|
||||
database = _normalize_probe_component(parsed.database or "")
|
||||
if not _is_disposable_probe_host(host):
|
||||
raise RuntimeError(
|
||||
"迁移测试数据库主机名必须使用 migration-probe 或 disposable-probe 前缀"
|
||||
)
|
||||
if not _is_disposable_probe_database(database):
|
||||
raise RuntimeError(
|
||||
"迁移测试数据库名必须使用 migration-probe 或 disposable-probe 前缀"
|
||||
)
|
||||
|
||||
return raw_url
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def migration_database_url() -> Iterator[str]:
|
||||
if not MIGRATION_TEST_DATABASE_URL:
|
||||
pytest.skip("仅在显式配置 MIGRATION_TEST_DATABASE_URL 时运行一次性 PostgreSQL 迁移测试")
|
||||
database_url = _require_disposable_probe_url(MIGRATION_TEST_DATABASE_URL)
|
||||
previous_database_url = os.environ.get("DATABASE_URL")
|
||||
os.environ["DATABASE_URL"] = database_url
|
||||
get_settings.cache_clear()
|
||||
|
||||
try:
|
||||
resolved_url = get_settings().resolved_database_url
|
||||
if make_url(resolved_url) != make_url(database_url):
|
||||
raise RuntimeError("运行时数据库 URL 未解析到 MIGRATION_TEST_DATABASE_URL")
|
||||
yield database_url
|
||||
finally:
|
||||
if previous_database_url is None:
|
||||
os.environ.pop("DATABASE_URL", None)
|
||||
else:
|
||||
os.environ["DATABASE_URL"] = previous_database_url
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _alembic_config(database_url: str) -> Config:
|
||||
config = Config(str(ALEMBIC_INI_PATH))
|
||||
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
|
||||
return config
|
||||
|
||||
|
||||
def _upgrade_head(database_url: str) -> None:
|
||||
get_settings.cache_clear()
|
||||
command.upgrade(_alembic_config(database_url), "head")
|
||||
|
||||
|
||||
def _downgrade_base(database_url: str) -> None:
|
||||
get_settings.cache_clear()
|
||||
command.downgrade(_alembic_config(database_url), "base")
|
||||
|
||||
|
||||
def _table_names(engine: Engine) -> set[str]:
|
||||
return set(inspect(engine).get_table_names(schema="public"))
|
||||
|
||||
|
||||
def _assert_unique_constraint(
|
||||
engine: Engine,
|
||||
table_name: str,
|
||||
constraint_name: str,
|
||||
expected_columns: tuple[str, ...],
|
||||
) -> None:
|
||||
constraints = {
|
||||
str(item["name"]): tuple(item["column_names"])
|
||||
for item in inspect(engine).get_unique_constraints(table_name, schema="public")
|
||||
}
|
||||
assert constraints.get(constraint_name) == expected_columns
|
||||
|
||||
|
||||
def _assert_indexes(
|
||||
engine: Engine,
|
||||
table_name: str,
|
||||
expected_indexes: dict[str, tuple[str, ...]],
|
||||
) -> None:
|
||||
indexes = {
|
||||
str(item["name"]): tuple(item["column_names"])
|
||||
for item in inspect(engine).get_indexes(table_name, schema="public")
|
||||
}
|
||||
for index_name, expected_columns in expected_indexes.items():
|
||||
assert indexes.get(index_name) == expected_columns
|
||||
|
||||
|
||||
def _assert_cascade_foreign_key(engine: Engine, table_name: str) -> None:
|
||||
foreign_keys = inspect(engine).get_foreign_keys(table_name, schema="public")
|
||||
matching = [
|
||||
item
|
||||
for item in foreign_keys
|
||||
if item["constrained_columns"] == ["expense_case_id"]
|
||||
and item["referred_table"] == "expense_cases"
|
||||
and item["referred_columns"] == ["id"]
|
||||
]
|
||||
assert len(matching) == 1
|
||||
assert str(matching[0].get("options", {}).get("ondelete", "")).upper() == "CASCADE"
|
||||
|
||||
|
||||
def _assert_head_schema(engine: Engine) -> None:
|
||||
names = _table_names(engine)
|
||||
assert MIGRATION_OWNED_TABLES.issubset(names)
|
||||
assert "alembic_version" in names
|
||||
|
||||
with engine.connect() as connection:
|
||||
assert connection.scalar(text("SELECT version_num FROM alembic_version")) == HEAD_REVISION
|
||||
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"expense_cases",
|
||||
"uq_expense_cases_tenant_case_no",
|
||||
("tenant_id", "case_no"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"expense_case_links",
|
||||
"uq_expense_case_links_resource",
|
||||
("resource_type", "resource_id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"business_events",
|
||||
"uq_business_event_idempotency",
|
||||
("tenant_id", "aggregate_type", "aggregate_id", "event_type", "idempotency_key"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"auth_sessions",
|
||||
"uq_auth_sessions_token_hash",
|
||||
("token_hash",),
|
||||
)
|
||||
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"expense_cases",
|
||||
{
|
||||
"ix_expense_cases_tenant_stage": ("tenant_id", "current_stage"),
|
||||
"ix_expense_cases_tenant_status": ("tenant_id", "status"),
|
||||
},
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"expense_case_links",
|
||||
{"ix_expense_case_links_tenant_case": ("tenant_id", "expense_case_id")},
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"business_events",
|
||||
{
|
||||
"ix_business_events_aggregate": ("aggregate_type", "aggregate_id"),
|
||||
"ix_business_events_outbox": ("delivery_status", "occurred_at"),
|
||||
"ix_business_events_tenant_case_time": (
|
||||
"tenant_id",
|
||||
"expense_case_id",
|
||||
"occurred_at",
|
||||
),
|
||||
},
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"auth_sessions",
|
||||
{
|
||||
"ix_auth_sessions_principal_active": (
|
||||
"principal_type",
|
||||
"revoked_at",
|
||||
"expires_at",
|
||||
),
|
||||
"ix_auth_sessions_tenant_username": ("tenant_id", "username"),
|
||||
},
|
||||
)
|
||||
_assert_cascade_foreign_key(engine, "expense_case_links")
|
||||
_assert_cascade_foreign_key(engine, "business_events")
|
||||
|
||||
|
||||
def _assert_runtime_cascade(engine: Engine) -> None:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO expense_cases (
|
||||
id, tenant_id, case_no, scene_code, title, current_stage, status
|
||||
) VALUES (
|
||||
'migration-probe-case', 'migration-probe', 'CASE-MIGRATION-PROBE',
|
||||
'reimbursement', '迁移级联验证', 'claiming', 'active'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO expense_case_links (
|
||||
id, tenant_id, expense_case_id, resource_type, resource_id, relation_type
|
||||
) VALUES (
|
||||
'migration-probe-link', 'migration-probe', 'migration-probe-case',
|
||||
'expense_claim', 'migration-probe-claim', 'claim'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO business_events (
|
||||
id, tenant_id, expense_case_id, aggregate_type, aggregate_id,
|
||||
event_type, event_version, idempotency_key, correlation_id,
|
||||
actor_id, actor_type, payload_json, delivery_status, delivery_attempts
|
||||
) VALUES (
|
||||
'migration-probe-event', 'migration-probe', 'migration-probe-case',
|
||||
'expense_claim', 'migration-probe-claim', 'claim_draft_created', 1,
|
||||
'migration-probe-idempotency', 'migration-probe-correlation',
|
||||
'migration-probe-user', 'user', '{}', 'pending', 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text("DELETE FROM expense_cases WHERE id = 'migration-probe-case'")
|
||||
)
|
||||
assert connection.scalar(
|
||||
text("SELECT COUNT(*) FROM expense_case_links WHERE id = 'migration-probe-link'")
|
||||
) == 0
|
||||
assert connection.scalar(
|
||||
text("SELECT COUNT(*) FROM business_events WHERE id = 'migration-probe-event'")
|
||||
) == 0
|
||||
|
||||
|
||||
def _create_legacy_sentinel(engine: Engine) -> None:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
CREATE TABLE {LEGACY_PROBE_TABLE} (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
payload VARCHAR(255) NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
INSERT INTO {LEGACY_PROBE_TABLE} (id, payload)
|
||||
VALUES ('legacy-sentinel', 'must-survive-migration-cycle')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _assert_legacy_sentinel(engine: Engine) -> None:
|
||||
assert LEGACY_PROBE_TABLE in _table_names(engine)
|
||||
with engine.connect() as connection:
|
||||
payload = connection.scalar(
|
||||
text(
|
||||
f"SELECT payload FROM {LEGACY_PROBE_TABLE} "
|
||||
"WHERE id = 'legacy-sentinel'"
|
||||
)
|
||||
)
|
||||
assert payload == "must-survive-migration-cycle"
|
||||
|
||||
|
||||
def _assert_base_schema(engine: Engine) -> None:
|
||||
names = _table_names(engine)
|
||||
assert not MIGRATION_OWNED_TABLES.intersection(names)
|
||||
assert "alembic_version" in names
|
||||
with engine.connect() as connection:
|
||||
assert connection.scalar(text("SELECT COUNT(*) FROM alembic_version")) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"database_url",
|
||||
[
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
"postgresql+psycopg://postgres:postgres@x-financial-local-postgres:5432/x_financial",
|
||||
"postgresql+psycopg://probe:probe@migration-probe-123:5432/x_financial",
|
||||
],
|
||||
)
|
||||
def test_disposable_database_guard_rejects_unsafe_urls(database_url: str) -> None:
|
||||
with pytest.raises(RuntimeError):
|
||||
_require_disposable_probe_url(database_url)
|
||||
|
||||
|
||||
def test_alembic_migration_cycle_on_disposable_postgres(
|
||||
migration_database_url: str,
|
||||
) -> None:
|
||||
engine = create_engine(migration_database_url, poolclass=NullPool)
|
||||
try:
|
||||
assert _table_names(engine) == set(), "迁移测试必须从全新空库开始"
|
||||
assert validate_migration_state(engine).revision is None
|
||||
|
||||
_upgrade_head(migration_database_url)
|
||||
_assert_head_schema(engine)
|
||||
assert validate_migration_state(engine).revision == HEAD_REVISION
|
||||
|
||||
_upgrade_head(migration_database_url)
|
||||
_assert_head_schema(engine)
|
||||
_assert_runtime_cascade(engine)
|
||||
|
||||
_create_legacy_sentinel(engine)
|
||||
_downgrade_base(migration_database_url)
|
||||
|
||||
_assert_base_schema(engine)
|
||||
_assert_legacy_sentinel(engine)
|
||||
assert validate_migration_state(engine).revision is None
|
||||
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE expense_cases (id VARCHAR(36) PRIMARY KEY)"))
|
||||
with pytest.raises(MigrationPreflightError, match="migration-owned tables exist"):
|
||||
validate_migration_state(engine)
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("DROP TABLE expense_cases"))
|
||||
assert validate_migration_state(engine).revision is None
|
||||
|
||||
_upgrade_head(migration_database_url)
|
||||
_assert_head_schema(engine)
|
||||
_assert_legacy_sentinel(engine)
|
||||
finally:
|
||||
engine.dispose()
|
||||
135
server/tests/test_migration_preflight.py
Normal file
135
server/tests/test_migration_preflight.py
Normal 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)
|
||||
26
server/tests/test_schema_ownership.py
Normal file
26
server/tests/test_schema_ownership.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
|
||||
|
||||
|
||||
def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
try:
|
||||
create_legacy_schema(engine)
|
||||
|
||||
table_names = set(inspect(engine).get_table_names())
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
assert MIGRATION_OWNED_TABLES == frozenset(
|
||||
{
|
||||
"auth_sessions",
|
||||
"business_events",
|
||||
"expense_case_links",
|
||||
"expense_cases",
|
||||
}
|
||||
)
|
||||
assert table_names
|
||||
assert table_names.isdisjoint(MIGRATION_OWNED_TABLES)
|
||||
Reference in New Issue
Block a user