fix(migrations): repair tenant identity lookup indexes

This commit is contained in:
caoxiaozhu
2026-07-20 10:28:31 +08:00
parent 07241b4648
commit 372e35d62a
5 changed files with 517 additions and 5 deletions

View File

@@ -46,9 +46,29 @@ from app.models.risk_observation import RiskObservation
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
HEAD_REVISION = "20260717_0028"
HEAD_REVISION = "20260718_0029"
SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
TENANT_IDENTITY_LOOKUP_INDEXES = (
(
"organization_units",
"unit_code",
"ix_organization_units_unit_code",
"uq_organization_units_tenant_code",
),
(
"employees",
"employee_no",
"ix_employees_employee_no",
"uq_employees_tenant_employee_no",
),
(
"employees",
"email",
"ix_employees_email",
"uq_employees_tenant_email",
),
)
class _UnsupportedDialectOperationGuard:
@@ -185,6 +205,30 @@ def _assert_indexes(
assert indexes.get(index_name) == expected_columns
def _assert_tenant_identity_lookup_indexes(
engine: Engine,
*,
unique: bool,
) -> None:
inspector = inspect(engine)
for table_name, column_name, index_name, constraint_name in (
TENANT_IDENTITY_LOOKUP_INDEXES
):
indexes = {
str(item["name"]): item
for item in inspector.get_indexes(table_name, schema="public")
}
index = indexes[index_name]
assert tuple(index["column_names"]) == (column_name,)
assert bool(index.get("unique", False)) is unique
_assert_unique_constraint(
engine,
table_name,
constraint_name,
("tenant_id", column_name),
)
def _assert_postgresql_index_predicate(
engine: Engine,
table_name: str,
@@ -1652,6 +1696,8 @@ def _assert_base_schema(engine: Engine) -> None:
("20260717_0027_knowledge_tenant_security.py", "downgrade"),
("20260717_0028_hermes_ontology_tenant_security.py", "upgrade"),
("20260717_0028_hermes_ontology_tenant_security.py", "downgrade"),
("20260718_0029_tenant_identity_lookup_indexes.py", "upgrade"),
("20260718_0029_tenant_identity_lookup_indexes.py", "downgrade"),
],
)
def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
@@ -1668,6 +1714,116 @@ def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
assert operation_guard.mutation_calls == []
@pytest.mark.parametrize(
("direction", "initial_unique", "expected_unique"),
[
("upgrade", True, False),
("downgrade", False, True),
],
)
def test_tenant_identity_lookup_index_migration_replaces_legacy_index_uniqueness(
monkeypatch: pytest.MonkeyPatch,
direction: str,
initial_unique: bool,
expected_unique: bool,
) -> None:
migration = _load_migration_module(
"20260718_0029_tenant_identity_lookup_indexes.py"
)
targets = migration._TARGET_INDEXES
index_states = {
target.index_name: initial_unique
for target in targets
}
mutation_calls: list[tuple[str, str, str, tuple[str, ...] | None, bool | None]] = []
class _IndexOperation:
bind = SimpleNamespace(dialect=SimpleNamespace(name="postgresql"))
def get_bind(self) -> SimpleNamespace:
return self.bind
def drop_index(self, index_name: str, *, table_name: str) -> None:
mutation_calls.append(("drop", table_name, index_name, None, None))
index_states.pop(index_name)
def create_index(
self,
index_name: str,
table_name: str,
columns: list[str],
*,
unique: bool,
) -> None:
mutation_calls.append(
("create", table_name, index_name, tuple(columns), unique)
)
index_states[index_name] = unique
migration.op = _IndexOperation()
monkeypatch.setattr(migration, "_validated_present_targets", lambda: targets)
monkeypatch.setattr(
migration,
"_index_uniqueness",
lambda target: index_states.get(target.index_name),
)
monkeypatch.setattr(
migration,
"_assert_tenant_unique_constraint",
lambda _target: None,
)
monkeypatch.setattr(
migration,
"_cross_tenant_duplicate_count",
lambda _target: 0,
)
getattr(migration, direction)()
assert index_states == {
target.index_name: expected_unique
for target in targets
}
assert len(mutation_calls) == len(targets) * 2
assert all(
call[4] is expected_unique
for call in mutation_calls
if call[0] == "create"
)
def test_tenant_identity_lookup_index_migration_refuses_lossy_downgrade(
monkeypatch: pytest.MonkeyPatch,
) -> None:
migration = _load_migration_module(
"20260718_0029_tenant_identity_lookup_indexes.py"
)
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
migration.op = operation_guard
monkeypatch.setattr(
migration,
"_validated_present_targets",
lambda: migration._TARGET_INDEXES,
)
monkeypatch.setattr(migration, "_index_uniqueness", lambda _target: False)
monkeypatch.setattr(
migration,
"_cross_tenant_duplicate_count",
lambda _target: 1,
)
with pytest.raises(RuntimeError, match="cross-tenant duplicate values exist") as error:
migration.downgrade()
for expected_dimension in (
"organization_units.unit_code",
"employees.employee_no",
"employees.email",
):
assert expected_dimension in str(error.value)
assert operation_guard.mutation_calls == []
def test_risk_disposition_snapshot_migration_refuses_lossy_downgrade() -> None:
migration = _load_migration_module("20260716_0012_risk_disposition_response_snapshot.py")
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
@@ -1933,6 +2089,76 @@ def test_risk_waiver_model_declares_decision_metadata_constraints() -> None:
assert "reject_waiver" in str(event_action_constraint.sqltext)
def _recreate_legacy_global_tenant_identity_indexes(engine: Engine) -> None:
with engine.begin() as connection:
for table_name, column_name, index_name, _constraint_name in (
TENANT_IDENTITY_LOOKUP_INDEXES
):
connection.execute(text(f'DROP INDEX IF EXISTS "{index_name}"'))
connection.execute(
text(
f'CREATE UNIQUE INDEX "{index_name}" '
f'ON "{table_name}" ("{column_name}")'
)
)
def _create_cross_tenant_identity_duplicate_probe(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO organization_units (
id, tenant_id, unit_code, name, unit_type
) VALUES
(
'tenant-index-org-default', 'default',
'TENANT-INDEX-DUP', '租户索引默认组织', 'department'
),
(
'tenant-index-org-platform', 'platform',
'TENANT-INDEX-DUP', '租户索引平台组织', 'department'
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO employees (
id, tenant_id, employee_no, name, email, position, grade,
employment_status, sync_state, compliance_score, spotlight,
organization_unit_id
) VALUES
(
'tenant-index-employee-default', 'default',
'TENANT-INDEX-DUP', '租户索引默认员工',
'tenant-index-dup@example.com', '员工', 'P3',
'在职', '已同步', 100, false,
'tenant-index-org-default'
),
(
'tenant-index-employee-platform', 'platform',
'TENANT-INDEX-DUP', '租户索引平台员工',
'tenant-index-dup@example.com', '员工', 'P3',
'在职', '已同步', 100, false,
'tenant-index-org-platform'
)
"""
)
)
def _delete_cross_tenant_identity_duplicate_probe(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
text("DELETE FROM employees WHERE id LIKE 'tenant-index-employee-%'")
)
connection.execute(
text("DELETE FROM organization_units WHERE id LIKE 'tenant-index-org-%'")
)
@pytest.mark.parametrize(
"database_url",
[
@@ -1973,10 +2199,29 @@ def test_alembic_migration_cycle_on_disposable_postgres(
_upgrade_revision(migration_database_url, "20260716_0019")
_create_legacy_connector_payload_probe(engine)
_upgrade_revision(migration_database_url, "20260717_0028")
create_legacy_schema(engine)
_recreate_legacy_global_tenant_identity_indexes(engine)
_upgrade_head(migration_database_url)
_assert_and_delete_legacy_connector_payload_probe(engine)
_assert_head_schema(engine)
_assert_tenant_identity_lookup_indexes(engine, unique=False)
assert validate_migration_state(engine).revision == HEAD_REVISION
_create_cross_tenant_identity_duplicate_probe(engine)
with pytest.raises(
RuntimeError,
match="cross-tenant duplicate values exist",
) as tenant_index_error:
_downgrade_revision(migration_database_url, "20260717_0028")
for expected_dimension in (
"organization_units.unit_code",
"employees.employee_no",
"employees.email",
):
assert expected_dimension in str(tenant_index_error.value)
assert validate_migration_state(engine).revision == HEAD_REVISION
_assert_tenant_identity_lookup_indexes(engine, unique=False)
_delete_cross_tenant_identity_duplicate_probe(engine)
_assert_savings_runtime_invariants(engine)
_assert_commercial_runtime_invariants(engine)
_assert_financial_connector_runtime_invariants(engine)
@@ -2016,7 +2261,6 @@ def test_alembic_migration_cycle_on_disposable_postgres(
"WHERE id = 'downgrade-refusal-baseline'"
)
)
create_legacy_schema(engine)
assert "expense_claims" in _table_names(engine)
_upgrade_head(migration_database_url)
@@ -2059,6 +2303,7 @@ def test_alembic_migration_cycle_on_disposable_postgres(
_upgrade_head(migration_database_url)
_assert_head_schema(engine)
_assert_tenant_identity_lookup_indexes(engine, unique=False)
_assert_legacy_sentinel(engine)
finally:
engine.dispose()