"""Regression: ``sync_scopes_for_integration`` must NOT un-soft-delete a scope the admin has decommissioned (prod incident 2026-05-21). Bug: ``on_duplicate_key_update`` set ``deleted_at=None`` unconditionally when the driver re-listed a scope. Combined with the deliberate "don't overwrite admin-set ``status``" rule, this half-revived decommissioned scopes — ``status`` stayed ``closed`` / ``decommissioned`` but ``deleted_at`` got cleared. The list filter (``deleted_at IS NULL`` plus default ``status != 'closed'``) then leaked the legacy ``status='decommissioned'`` rows back into the 订阅列表 tab. Real incident: 10 GCP org-root projects (decommissioned 2026-05-18, leaked back on 2026-05-19 20:24 sync). Lock-in: * A decommissioned scope (``decommissioned_at IS NOT NULL``, ``deleted_at IS NOT NULL``) re-listed by the driver MUST keep its ``deleted_at`` timestamp. The admin tombstone wins. * A non-decommissioned scope that was soft-deleted by some OTHER path (e.g. manual cleanup) re-listed by the driver SHOULD have ``deleted_at`` cleared — the un-soft-delete path is still valid for legitimate re-discovery. * ``status`` is never overwritten on update (existing invariant — belt-and-suspenders covered here). """ from __future__ import annotations import uuid from contextlib import asynccontextmanager from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.lifecycle_service import sync_scopes_for_integration from app.domains.cmdb.models import ( SCOPE_STATUS_ACTIVE, SCOPE_STATUS_CLOSED, CmdbCloudScope, ) from app.integrations.driver_base import CloudScopeInfo from app.integrations.models import Integration async def _make_integration(db: AsyncSession) -> Integration: row = Integration( id=uuid.uuid4(), kind="cloud", provider="gcp", name="gcp-decom-resurrection-test", slug=f"gcp-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=[], ) db.add(row) await db.commit() return row def _patch_session_factory(db_session: AsyncSession): @asynccontextmanager async def _factory(): yield db_session return patch( "app.domains.cmdb.lifecycle_service.async_session_factory", new=_factory, ) class _FakeDriver: def __init__(self, scopes: list[CloudScopeInfo]) -> None: self._scopes = scopes self.close = AsyncMock() async def list_scopes(self): return list(self._scopes) def _patch_driver(driver: _FakeDriver): async def _loader(session, integration): return driver return patch( "app.domains.cmdb.lifecycle_service._load_cloud_driver", new=_loader, ) @pytest.mark.asyncio async def test_decommissioned_scope_resurrection_blocked( db_session: AsyncSession, ) -> None: integration = await _make_integration(db_session) decom_ts = datetime(2026, 5, 18, 16, 46, 57, tzinfo=UTC) deleted_ts = datetime(2026, 5, 18, 16, 46, 58, tzinfo=UTC) # Seed a decommissioned + soft-deleted scope. existing = CmdbCloudScope( id=str(uuid.uuid4()), integration_id=str(integration.id), scope_type="gcp_project", external_id="ai-test-462404", display_name="AI-Test", status=SCOPE_STATUS_CLOSED, decommissioned_at=decom_ts, decommission_reason="bulk-cleanup", deleted_at=deleted_ts, raw_data={"original": True}, ) db_session.add(existing) await db_session.commit() # Driver re-lists the same project (still cloud-side ACTIVE — the # exact condition that bit prod on 2026-05-19). driver = _FakeDriver([ CloudScopeInfo( scope_type="gcp_project", external_id="ai-test-462404", display_name="AI-Test", status="active", raw={"re-discovered": True}, ), ]) with _patch_session_factory(db_session), _patch_driver(driver): await sync_scopes_for_integration(str(integration.id)) refreshed = (await db_session.execute( select(CmdbCloudScope).where( CmdbCloudScope.external_id == "ai-test-462404", ) )).scalar_one() # The whole point of the regression: deleted_at MUST stay set. assert refreshed.deleted_at is not None, ( "decommissioned scope was resurrected by sync — " "this is the 2026-05-21 regression" ) # Audit columns survive. assert refreshed.decommissioned_at is not None assert refreshed.status == SCOPE_STATUS_CLOSED @pytest.mark.asyncio async def test_non_decommissioned_undelete_still_works( db_session: AsyncSession, ) -> None: """The legitimate undelete path (scope was soft-deleted with NO decommission audit, then re-appears on the cloud) must still clear ``deleted_at``. Otherwise the fix would dead-lock the re-discovery use case.""" integration = await _make_integration(db_session) deleted_ts = datetime(2026, 5, 1, 0, 0, 0, tzinfo=UTC) existing = CmdbCloudScope( id=str(uuid.uuid4()), integration_id=str(integration.id), scope_type="gcp_project", external_id="legit-project", display_name="legit", status=SCOPE_STATUS_ACTIVE, decommissioned_at=None, deleted_at=deleted_ts, raw_data={}, ) db_session.add(existing) await db_session.commit() driver = _FakeDriver([ CloudScopeInfo( scope_type="gcp_project", external_id="legit-project", display_name="legit", status="active", raw={}, ), ]) with _patch_session_factory(db_session), _patch_driver(driver): await sync_scopes_for_integration(str(integration.id)) refreshed = (await db_session.execute( select(CmdbCloudScope).where( CmdbCloudScope.external_id == "legit-project", ) )).scalar_one() assert refreshed.deleted_at is None assert refreshed.decommissioned_at is None @pytest.mark.asyncio async def test_legacy_decommissioned_literal_also_protected( db_session: AsyncSession, ) -> None: """Defence-in-depth: even if a row carries the legacy ``status='decommissioned'`` literal (pre-migration-196 prod state), the sync must still leave ``deleted_at`` alone as long as the ``decommissioned_at`` audit timestamp is present. The migration normalizes the literal but the code must be resilient regardless.""" integration = await _make_integration(db_session) decom_ts = datetime(2026, 5, 18, 16, 46, 57, tzinfo=UTC) deleted_ts = datetime(2026, 5, 18, 16, 46, 58, tzinfo=UTC) existing = CmdbCloudScope( id=str(uuid.uuid4()), integration_id=str(integration.id), scope_type="gcp_project", external_id="legacy-literal-test", display_name="legacy", status="decommissioned", # legacy literal — pre-migration-196 decommissioned_at=decom_ts, deleted_at=deleted_ts, raw_data={}, ) db_session.add(existing) await db_session.commit() driver = _FakeDriver([ CloudScopeInfo( scope_type="gcp_project", external_id="legacy-literal-test", display_name="legacy", status="active", raw={}, ), ]) with _patch_session_factory(db_session), _patch_driver(driver): await sync_scopes_for_integration(str(integration.id)) refreshed = (await db_session.execute( select(CmdbCloudScope).where( CmdbCloudScope.external_id == "legacy-literal-test", ) )).scalar_one() assert refreshed.deleted_at is not None # status preserved per existing invariant (admin-set wins). assert refreshed.status == "decommissioned"