"""Tests for the per-capability ``cmdb.resource_sync`` handler. Migration 047 replaced the single global ``cmdb.cloud_sync`` handler with ``cmdb.resource_sync`` (one scheduled_jobs row per integration × capability, config-driven). These tests cover: * happy path — config.{integration_id, capability} routes into ``cmdb_sync.run_sync`` with a single-element capabilities list; * skip paths — missing config, disabled integration, wrong kind, capability not in the integration's enabled list. ``run_sync`` is monkeypatched; we never hit a real cloud driver. """ from __future__ import annotations import uuid from unittest.mock import AsyncMock import pytest from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.integrations.models import Integration async def _make_integration( db_session: AsyncSession, *, enabled: bool = True, capabilities=None, ) -> Integration: iid = uuid.uuid4() row = Integration( id=iid, kind="cloud", provider="aws", name=f"tenant-{str(iid)[:6]}", slug=f"t-{str(iid)[:6]}", enabled=enabled, is_primary=False, capabilities=capabilities, ) db_session.add(row) await db_session.commit() return row @pytest.fixture def _patched_session_factory(db_engine, monkeypatch): """Point ``async_session_factory`` at the test engine so the handler's fresh sessions see the committed fixture rows.""" factory = async_sessionmaker(db_engine, expire_on_commit=False) from app.core import database as db_module monkeypatch.setattr(db_module, "async_session_factory", factory) return factory @pytest.mark.asyncio async def test_resource_sync_happy_path( db_session: AsyncSession, monkeypatch, _patched_session_factory, ): integ = await _make_integration( db_session, enabled=True, capabilities=["servers", "domains"], ) call_log: dict = {} async def fake_run_sync(*, integration_id, job_id, capabilities, triggered_by_user_id): call_log["integration_id"] = integration_id call_log["capabilities"] = capabilities from app.domains.cmdb import sync as cmdb_sync monkeypatch.setattr(cmdb_sync, "run_sync", fake_run_sync) from app.scheduled_jobs.handlers import run_resource_sync result = await run_resource_sync( db_session, {"integration_id": str(integ.id), "capability": "servers"}, ) assert call_log == { "integration_id": str(integ.id), "capabilities": ["servers"], } assert result["integration_id"] == str(integ.id) assert result["capability"] == "servers" assert "job_id" in result @pytest.mark.asyncio async def test_resource_sync_skips_missing_config(db_session: AsyncSession): from app.scheduled_jobs.handlers import run_resource_sync result = await run_resource_sync(db_session, {}) assert result["status"] == "skipped" assert "missing" in result["reason"].lower() @pytest.mark.asyncio async def test_resource_sync_skips_disabled_integration( db_session: AsyncSession, monkeypatch, _patched_session_factory, ): integ = await _make_integration(db_session, enabled=False) from app.domains.cmdb import sync as cmdb_sync monkeypatch.setattr(cmdb_sync, "run_sync", AsyncMock()) from app.scheduled_jobs.handlers import run_resource_sync result = await run_resource_sync( db_session, {"integration_id": str(integ.id), "capability": "servers"}, ) assert result["status"] == "skipped" assert result["reason"] == "integration disabled" @pytest.mark.asyncio async def test_resource_sync_skips_capability_not_enabled( db_session: AsyncSession, monkeypatch, _patched_session_factory, ): integ = await _make_integration( db_session, enabled=True, capabilities=["servers"], ) from app.domains.cmdb import sync as cmdb_sync mock = AsyncMock() monkeypatch.setattr(cmdb_sync, "run_sync", mock) from app.scheduled_jobs.handlers import run_resource_sync result = await run_resource_sync( db_session, {"integration_id": str(integ.id), "capability": "iam"}, ) assert result["status"] == "skipped" assert result["reason"] == "capability not enabled on this integration" mock.assert_not_awaited() @pytest.mark.asyncio async def test_run_sync_error_path_persists_provider_partial_failures( db_session: AsyncSession, monkeypatch, _patched_session_factory, ): """Regression: when ``_sync_servers`` raises (e.g. ``_safe_sweep`` trips the >5%-drop guard), the driver's already-accumulated ``partial_failures`` MUST be drained into ``cmdb_sync_jobs.capability_results..partial_failures``. Pre-fix the error branch in ``run_sync`` set only ``{status: failed, error: ...}`` and discarded the legs. Production symptom (PR #340 post-deploy, 2026-05-17): GCP servers cron blocked by sweep guard with the GCP driver's new zone-warning legs sitting in ``provider.partial_failures`` — they never reached ``capability_results``, so the admin sync-history UI still showed "refusing to sweep" with NO indication of which zone was bad. """ integ = await _make_integration( db_session, enabled=True, capabilities=["servers"], ) # ``run_sync`` early-exits if ``secrets_encrypted`` is empty (the # "no credentials configured" guard at the top of ``_run_sync_impl``), # so we set a non-empty sentinel value and stub ``decrypt_fields`` # below to return fake creds. The provider class is monkey-patched # so the actual creds never reach a real cloud SDK. integ.secrets_encrypted = "test-non-empty-sentinel" integ.secrets_nonce = "test-nonce" await db_session.commit() from app.domains.cmdb import sync as cmdb_sync from app.domains.cmdb.providers.exceptions import ProviderTransientError def fake_decrypt(secrets_encrypted, secrets_nonce, *, integration_id): return {"access_key_id": "AKIATEST", "secret_access_key": "x"} monkeypatch.setattr(cmdb_sync, "decrypt_fields", fake_decrypt) # ``run_sync`` resolves ``async_session_factory`` via the module-local # name (``from app.core.database import async_session_factory``). # The ``_patched_session_factory`` fixture rewrites the attribute on # ``app.core.database`` but the already-imported name in # ``cmdb.sync`` is stale — patch the local name too so this test's # ``run_sync`` actually uses the test DB engine. monkeypatch.setattr( cmdb_sync, "async_session_factory", _patched_session_factory, ) # Replace ``_sync_servers`` with a stub that mirrors the production # path: populate ``provider.partial_failures`` with a zone-warning # leg, then raise ``ProviderTransientError`` (what ``_safe_sweep`` # raises on a >5%-drop refusal). The driver's existing legs must # ride through to ``capability_results``. async def fake_sync_servers(db, integration, job_id, provider, *, force_sweep, forced_meta): provider.partial_failures.append({ "leg": "project:test-proj:zone:asia-east1-c", "capability": "servers", "reason": "aggregatedList.zone_warning_UNREACHABLE", }) raise ProviderTransientError( "servers: provider returned 873 rows vs 943 baseline " "(previous_sync) (>5% drop) — refusing to sweep " "(likely partial driver failure).", ) monkeypatch.setattr(cmdb_sync, "_sync_servers", fake_sync_servers) # ``run_sync`` updates a pre-existing ``cmdb_sync_jobs`` row (the # worker handler creates it before dispatching). Seed it here so # the persistence path lands. from app.domains.cmdb.models import CmdbSyncJob job_id = str(uuid.uuid4()) db_session.add(CmdbSyncJob( id=uuid.UUID(job_id), integration_id=integ.id, triggered_by="scheduled", resource_types=["servers"], status="queued", )) await db_session.commit() # Drive ``run_sync`` end-to-end so the persistence layer (sync_jobs # row write) gets exercised — this is where the bug landed. await cmdb_sync.run_sync( integration_id=str(integ.id), job_id=job_id, capabilities=["servers"], triggered_by_user_id=None, ) # Read back the persisted row and assert BOTH halves are present: # the error message AND the drained partial_failures legs. ``run_sync`` # writes via its own session (from ``_patched_session_factory``); # use that same factory to read so we see the committed state, not # the outer test-fixture session's pre-commit snapshot. from sqlalchemy import select from app.domains.cmdb.models import CmdbSyncJob async with _patched_session_factory() as read_session: job = (await read_session.execute( select(CmdbSyncJob).where(CmdbSyncJob.id == job_id), )).scalar_one() assert job.status == "failed" cap_result = (job.capability_results or {}).get("servers") or {} assert cap_result.get("status") == "failed" assert "refusing to sweep" in (cap_result.get("error") or "") legs = cap_result.get("partial_failures") or [] assert len(legs) == 1, ( f"Expected 1 leg drained from provider.partial_failures into " f"the error-path capability_results, got {len(legs)}: {legs}. " "Pre-fix this was 0 — the visibility goal of PR #340's GCP " "zone-warning fix only lands when the error path also drains." ) assert legs[0]["reason"] == "aggregatedList.zone_warning_UNREACHABLE" assert legs[0]["leg"] == "project:test-proj:zone:asia-east1-c" assert cap_result.get("leg_count") == 1