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

@@ -58,14 +58,33 @@ pip install -e .[dev]
copy ..\\.env.example ..\\.env
```
3. 启动服务
3. 使用标准入口启动服务
```bash
uvicorn app.main:app --reload --app-dir src
cd ..
./start.sh server
```
标准入口在启动新的 FastAPI 进程前会自动执行 `alembic upgrade head`,迁移成功后才会
启动 Uvicorn。`./start.sh all` 在需要启动后端时也会复用同一流程。不要把直接运行
`uvicorn` 当作标准启动方式;手工调试 Uvicorn 时,需要先自行完成迁移。
## 迁移
```bash
alembic upgrade head
cd server
alembic -c alembic.ini upgrade head
```
一次性 PostgreSQL 迁移测试默认跳过,只有显式提供
`MIGRATION_TEST_DATABASE_URL` 时才会执行。为避免误操作开发库,测试会同时要求主机名
和数据库名使用 `migration-probe``disposable-probe` 安全前缀,并要求数据库初始为空。
```bash
cd server
MIGRATION_TEST_DATABASE_URL='postgresql+psycopg://migration_probe:migration_probe_pw@x-financial-migration-probe-123:5432/migration_probe' \
pytest -q tests/test_alembic_migrations.py
```
该测试覆盖首次升级、重复升级、关键表/约束/索引、外键级联、降级到 `base`、无关旧表
及哨兵数据保留,以及再次升级。请只把它指向无持久卷的一次性 PostgreSQL 容器。

View File

@@ -1,6 +1,7 @@
[alembic]
script_location = alembic
script_location = %(here)s/alembic
prepend_sys_path = .
path_separator = os
sqlalchemy.url = postgresql+psycopg://postgres:postgres@127.0.0.1:5432/x_financial
[loggers]

View File

@@ -379,6 +379,9 @@ ensure_dependencies() {
}
run_database_migrations() {
info "Checking database migration state..."
PYTHONPATH="$SCRIPT_DIR/src${PYTHONPATH:+:$PYTHONPATH}" \
"$PYTHON_BIN" -m app.db.migration_preflight
info "Applying database migrations..."
"$PYTHON_BIN" -m alembic -c "$SCRIPT_DIR/alembic.ini" upgrade head
info "Database migrations are up to date."

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
import sys
from dataclasses import dataclass
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import get_settings
from app.db.schema_ownership import MIGRATION_OWNED_TABLES
# 迁移脚本新增或删除自有表时,必须同步更新该映射并补充对应测试。
MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
"20260713_0001": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
}
),
"20260713_0002": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
}
),
}
if MIGRATION_OWNED_TABLES_BY_REVISION["20260713_0002"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
class MigrationPreflightError(RuntimeError):
"""Raised when the database schema cannot be safely advanced by Alembic."""
@dataclass(frozen=True)
class MigrationPreflightState:
revision: str | None
owned_tables: frozenset[str]
def _format_tables(table_names: frozenset[str]) -> str:
return ", ".join(sorted(table_names)) or "none"
def _validate_connection(connection: Connection) -> MigrationPreflightState:
table_names = frozenset(inspect(connection).get_table_names())
owned_tables = table_names & MIGRATION_OWNED_TABLES
if "alembic_version" not in table_names:
if owned_tables:
raise MigrationPreflightError(
"unversioned database contains migration-owned tables "
f"({_format_tables(owned_tables)}); refusing to guess, stamp, or repair"
)
return MigrationPreflightState(revision=None, owned_tables=owned_tables)
revisions = tuple(
str(revision)
for revision in connection.execute(
text("SELECT version_num FROM alembic_version")
).scalars()
)
if not revisions:
if owned_tables:
raise MigrationPreflightError(
"alembic_version has no recorded revision but migration-owned tables exist "
f"({_format_tables(owned_tables)}); refusing to guess, stamp, or repair"
)
return MigrationPreflightState(revision=None, owned_tables=owned_tables)
if len(revisions) > 1:
raise MigrationPreflightError(
"multiple Alembic revisions are recorded "
f"({', '.join(sorted(revisions))}); branch state is unsupported"
)
revision = revisions[0]
expected_tables = MIGRATION_OWNED_TABLES_BY_REVISION.get(revision)
if expected_tables is None:
raise MigrationPreflightError(
f"unknown Alembic revision {revision!r}; refusing to run migrations"
)
if owned_tables != expected_tables:
missing_tables = expected_tables - owned_tables
unexpected_tables = owned_tables - expected_tables
raise MigrationPreflightError(
f"migration-owned table set does not match revision {revision}: "
f"missing={_format_tables(missing_tables)}; "
f"unexpected={_format_tables(unexpected_tables)}"
)
return MigrationPreflightState(revision=revision, owned_tables=owned_tables)
def validate_migration_state(bind: Engine | Connection) -> MigrationPreflightState:
"""Inspect the database without changing its schema or Alembic revision state."""
if isinstance(bind, Engine):
with bind.connect() as connection:
return _validate_connection(connection)
return _validate_connection(bind)
def main() -> int:
settings = get_settings()
engine = create_engine(settings.resolved_database_url, pool_pre_ping=True)
try:
state = validate_migration_state(engine)
except (MigrationPreflightError, SQLAlchemyError) as exc:
print(f"Database migration preflight failed: {exc}", file=sys.stderr)
return 1
finally:
engine.dispose()
revision = state.revision or "unversioned/base"
print(
"Database migration preflight passed: "
f"revision={revision}; owned_tables={_format_tables(state.owned_tables)}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from sqlalchemy.engine import Connection, Engine
from app.db.base import Base
MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
{
"auth_sessions",
"expense_cases",
"expense_case_links",
"business_events",
}
)
def create_legacy_schema(bind: Engine | Connection) -> None:
"""创建仍由旧 bootstrap 管理的表,不越过 Alembic 的表所有权边界。"""
legacy_tables = [
table for table in Base.metadata.sorted_tables if table.name not in MIGRATION_OWNED_TABLES
]
Base.metadata.create_all(bind=bind, tables=legacy_tables)

View File

@@ -2,31 +2,16 @@ from __future__ import annotations
import threading
from sqlalchemy import inspect, select, text
from sqlalchemy import inspect, text
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.logging import get_logger
from app.db.base import Base
from app.db.schema_ownership import create_legacy_schema
from app.db.session import get_session_factory
from app.models.agent_asset import AgentAsset
from app.services.agent_foundation_asset_helpers import AgentFoundationAssetHelperMixin
from app.services.agent_foundation_asset_seed import AgentFoundationAssetSeedMixin
from app.services.agent_foundation_asset_topup import AgentFoundationAssetTopUpMixin
from app.services.agent_foundation_constants import (
ATTACHMENT_RULE_ASSET_CODE,
ATTACHMENT_RULE_RUNTIME_CONFIG,
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
DEMO_EXPENSE_CLAIM_SIGNATURES,
DEMO_PAYABLE_SIGNATURES,
DEMO_RECEIVABLE_SIGNATURES,
LEGACY_RULE_CODES,
PLATFORM_DESTINATION_LOCATION_RULE_CODE,
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
from app.services.agent_foundation_digital_employee_tasks import (
AgentFoundationDigitalEmployeeTaskMixin,
)
@@ -38,12 +23,6 @@ from app.services.agent_foundation_spreadsheets import AgentFoundationSpreadshee
logger = get_logger("app.services.agent_foundation")
_foundation_ready_lock = threading.RLock()
_foundation_ready_keys: set[str] = set()
MIGRATION_OWNED_TABLES = {
"auth_sessions",
"expense_cases",
"expense_case_links",
"business_events",
}
def prepare_agent_foundation() -> None:
@@ -83,12 +62,7 @@ class AgentFoundationService(
def _prepare_foundation(self) -> None:
try:
legacy_bootstrap_tables = [
table
for table in Base.metadata.sorted_tables
if table.name not in MIGRATION_OWNED_TABLES
]
Base.metadata.create_all(bind=self.db.get_bind(), tables=legacy_bootstrap_tables)
create_legacy_schema(self.db.get_bind())
self._ensure_agent_asset_schema()
self._ensure_financial_record_schema()
self._seed_agent_assets()

View File

@@ -7,7 +7,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.base import Base
from app.db.schema_ownership import create_legacy_schema
from app.models.budget import BudgetAllocation, BudgetReservation
from app.models.financial_record import ExpenseClaim
from app.schemas.budget import (
@@ -32,7 +32,7 @@ class BudgetService(BudgetPaginationMixin, BudgetSupportMixin):
def ensure_budget_ready(self) -> None:
# 复用当前 Session 连接,避免在业务事务中通过 Engine 隐式提交已 flush 的单据。
Base.metadata.create_all(bind=self.db.connection())
create_legacy_schema(self.db.connection())
exists = self.db.scalar(select(BudgetAllocation.id).limit(1))
if exists:
return

View File

@@ -10,7 +10,7 @@ from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from app.core.security import hash_password
from app.db.base import Base
from app.db.schema_ownership import create_legacy_schema
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
@@ -78,7 +78,7 @@ class HalfYearExpenseSimulationSeeder:
return self._run(apply=True)
def _run(self, *, apply: bool) -> SimulationSummary:
Base.metadata.create_all(bind=self.db.get_bind())
create_legacy_schema(self.db.get_bind())
departments = self._department_refs(apply=apply)
current_employee_count = self._employee_count()
planned_employees = self._build_new_employee_refs(departments, current_employee_count)

View File

@@ -7,7 +7,7 @@ from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from app.core.agent_enums import AgentName, AgentRunSource
from app.db.base import Base
from app.db.schema_ownership import create_legacy_schema
from app.models.agent_run import AgentRun, AgentToolCall
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
@@ -186,7 +186,7 @@ class DigitalEmployeeDashboardService:
)
def _ensure_storage_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind())
create_legacy_schema(self.db.get_bind())
def _fetch_runs(self, *, start: datetime, limit: int) -> list[AgentRun]:
stmt = (

View File

@@ -1,8 +1,8 @@
from __future__ import annotations
from collections import Counter
from datetime import UTC, date, datetime
import threading
from collections import Counter
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.logging import get_logger
from app.core.security import hash_password
from app.db.base import Base
from app.db.schema_ownership import create_legacy_schema
from app.db.session import get_session_factory
from app.models.employee import Employee
from app.models.employee_change_log import EmployeeChangeLog
@@ -28,11 +28,9 @@ from app.schemas.employee import (
EmployeeStatusSummaryRead,
EmployeeUpdate,
)
from app.services.employee_import import EmployeeImportCoordinator
from app.services.employee_bank_info import apply_default_bank_info
from app.services.employee_import import EmployeeImportCoordinator
from app.services.employee_schema import ensure_employee_schema
from app.services.employee_serialization import serialize_employee
from app.services.employee_spreadsheet import build_import_template_bytes
from app.services.employee_seed import (
CANONICAL_DEPARTMENT_CODES,
EMPLOYEE_DEFINITIONS,
@@ -44,6 +42,8 @@ from app.services.employee_seed import (
ROLE_PERMISSION_MAP,
normalize_organization_unit_code,
)
from app.services.employee_serialization import serialize_employee
from app.services.employee_spreadsheet import build_import_template_bytes
from app.services.employee_time import (
format_date,
format_datetime,
@@ -108,7 +108,7 @@ class EmployeeService:
def _ensure_directory_ready_uncached(self) -> None:
try:
Base.metadata.create_all(bind=self.db.get_bind())
create_legacy_schema(self.db.get_bind())
ensure_employee_schema(self.db)
self._prune_extra_seed_employees()
self._seed_roles()

View File

@@ -8,16 +8,20 @@ from datetime import datetime
from sqlalchemy import inspect, text
from sqlalchemy.orm import Session
from app.core.admin_secret import legacy_admin_secret_to_password_hash, read_admin_secret, verify_admin_secret
from app.core.admin_secret import (
legacy_admin_secret_to_password_hash,
read_admin_secret,
verify_admin_secret,
)
from app.core.config import get_settings
from app.core.secret_box import decrypt_secret, encrypt_secret
from app.core.security import hash_password, verify_password
from app.db.base import Base
from app.db.schema_ownership import create_legacy_schema
from app.db.session import get_session_factory
from app.models.hermes_config import HermesTaskConfig
from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
from app.models.hermes_config import HermesTaskConfig
from app.repositories.settings import SETTINGS_ROW_ID, SettingsRepository
from app.schemas.settings import SettingsRead, SettingsWrite
from app.services.hermes_sync import (
@@ -161,7 +165,7 @@ class SettingsService:
if cache_key not in self._schema_ready_keys:
with self._schema_ready_lock:
if cache_key not in self._schema_ready_keys:
Base.metadata.create_all(bind=self.db.get_bind())
create_legacy_schema(self.db.get_bind())
self._ensure_settings_schema()
self._schema_ready_keys.add(cache_key)

View File

@@ -8,7 +8,7 @@ from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.db.base import Base
from app.db.schema_ownership import create_legacy_schema
from app.models.agent_feedback import AgentOperationFeedback
from app.models.agent_run import AgentRun, AgentToolCall
from app.models.user_session_metric import UserSessionMetric
@@ -143,7 +143,7 @@ class SystemDashboardService:
)
def _ensure_storage_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind())
create_legacy_schema(self.db.get_bind())
def _fetch_runs(self, start: datetime, *, before: datetime | None = None) -> list[_DashboardRun]:
stmt = (

View 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()

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)

View 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)