"""Resolver tests for ``cmdb.sweep_settings``. The resolver is the DB-backed runtime knob behind the CMDB sweep guard. Three call sites read it every sweep tick — if the resolver misreads or fails to fall back, real production rows get either mass-soft-deleted (tolerance too high) or sync wedges forever (tolerance defaults wrong on a fresh DB). Coverage: 1. DB row missing → fallback to ``settings.CMDB_SYNC_SWEEP_DROP_TOLERANCE_PERCENT``. 2. DB row well-formed int → wins over settings. 3. DB row malformed (string, float-with-decimals, bool, out-of-band, non-JSON-scalar) → fallback. Never raises, never returns a value outside [0, 100]. 4. **Integration**: an admin-edited platform_setting value REALLY changes the trip behaviour of ``_safe_sweep`` end-to-end — without this, a resolver regression that always returned the fallback would silently make the runtime knob no-op while unit tests stay green. """ from __future__ import annotations import uuid from datetime import UTC, datetime import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.domains.cmdb.models import CmdbIamUser from app.domains.cmdb.providers.exceptions import ProviderTransientError from app.domains.cmdb.sweep_settings import ( PLATFORM_SETTING_SWEEP_DROP_TOLERANCE, drop_exceeds_tolerance, get_sweep_drop_tolerance_percent, minimum_observed_within_tolerance, ) from app.domains.cmdb.sync import _safe_sweep from app.integrations.models import Integration, PlatformSetting async def _seed_setting(db: AsyncSession, value) -> None: """Upsert the sweep-tolerance platform setting row with the given raw JSON value (kept as-is so we can test malformed cases too). """ existing = await db.get(PlatformSetting, PLATFORM_SETTING_SWEEP_DROP_TOLERANCE) if existing is None: db.add(PlatformSetting( key=PLATFORM_SETTING_SWEEP_DROP_TOLERANCE, value=value, description="test seed", )) else: existing.value = value await db.commit() async def _clear_setting(db: AsyncSession) -> None: existing = await db.get(PlatformSetting, PLATFORM_SETTING_SWEEP_DROP_TOLERANCE) if existing is not None: await db.delete(existing) await db.commit() @pytest.mark.parametrize( ("baseline", "observed", "tolerance", "exceeds", "minimum"), [ (2, 1, 20, True, 2), (5, 4, 20, False, 4), (6, 4, 20, True, 5), (10, 8, 20, False, 8), (10, 7, 20, True, 8), (1, 0, 100, False, 0), (10, 9, 0, True, 10), ], ) def test_drop_tolerance_uses_exact_ratio_without_rounding_holes( baseline: int, observed: int, tolerance: int, exceeds: bool, minimum: int, ) -> None: assert drop_exceeds_tolerance( baseline=baseline, observed=observed, tolerance_percent=tolerance, ) is exceeds assert minimum_observed_within_tolerance( baseline=baseline, tolerance_percent=tolerance, ) == minimum @pytest.mark.asyncio async def test_resolver_falls_back_to_settings_when_row_missing( db_session: AsyncSession, ): """No platform_settings row → return the .env / settings default.""" await _clear_setting(db_session) got = await get_sweep_drop_tolerance_percent(db_session) assert got == settings.CMDB_SYNC_SWEEP_DROP_TOLERANCE_PERCENT assert 0 <= got <= 100 @pytest.mark.asyncio async def test_resolver_reads_well_formed_int(db_session: AsyncSession): """Admin sets value to a valid int in band → resolver returns it, NOT the settings fallback.""" await _seed_setting(db_session, 35) got = await get_sweep_drop_tolerance_percent(db_session) assert got == 35 @pytest.mark.asyncio async def test_resolver_accepts_whole_number_float(db_session: AsyncSession): """JSON serialisation can surface ``20`` as ``20.0`` depending on the driver; the resolver coerces and accepts.""" await _seed_setting(db_session, 20.0) got = await get_sweep_drop_tolerance_percent(db_session) assert got == 20 @pytest.mark.asyncio async def test_resolver_accepts_numeric_string(db_session: AsyncSession): """Admin pasted ``"25"`` (string) via the JSON editor — coerce.""" await _seed_setting(db_session, "25") got = await get_sweep_drop_tolerance_percent(db_session) assert got == 25 @pytest.mark.parametrize("bad_value", [ 20.5, # non-integer float — percentage must be whole -1, # below band 101, # above band True, # bool is int subclass — must be rejected explicitly False, "abc", # non-numeric string None, # explicit null [20], # nested {"v": 20}, # dict ]) @pytest.mark.asyncio async def test_resolver_falls_back_on_malformed_values( db_session: AsyncSession, bad_value, ): """Anything not coercible to an int in [0, 100] → settings fallback. Critically: never returns a value outside the band, never raises.""" await _seed_setting(db_session, bad_value) got = await get_sweep_drop_tolerance_percent(db_session) assert got == settings.CMDB_SYNC_SWEEP_DROP_TOLERANCE_PERCENT assert 0 <= got <= 100 @pytest.mark.asyncio async def test_resolver_accepts_boundary_values(db_session: AsyncSession): """0 (STRICTEST — any drop trips) and 100 (drop-ratio leg DISABLED — floor collapses to 0) are both legitimate operator choices and must not fall back to the settings default.""" await _seed_setting(db_session, 0) assert await get_sweep_drop_tolerance_percent(db_session) == 0 await _seed_setting(db_session, 100) assert await get_sweep_drop_tolerance_percent(db_session) == 100 # ── Integration: platform_setting value really moves the trip floor ── async def _make_integration(db: AsyncSession) -> Integration: iid = uuid.uuid4() integ = Integration( id=iid, kind="cloud", provider="aws_cloud", name=f"sweep-tol-{str(iid)[:6]}", slug=f"st-{str(iid)[:6]}", enabled=True, is_primary=False, capabilities=None, ) db.add(integ) await db.commit() return integ async def _make_iam_user( db: AsyncSession, integration: Integration, *, user_name: str, last_seen_sync_job_id: str | None = None, ) -> CmdbIamUser: row = CmdbIamUser( id=uuid.uuid4(), integration_id=str(integration.id), user_name=user_name, sub_account_id="", arn=f"arn:aws:iam::123:user/{user_name}", user_id_external=f"AIDA{uuid.uuid4().hex.upper()[:16]}", attached_policies=[], groups=[], mfa_enabled=False, tags={}, raw_data={}, last_seen_sync_job_id=last_seen_sync_job_id, last_synced_at=datetime.now(UTC), ) db.add(row) await db.commit() return row @pytest.mark.asyncio async def test_platform_setting_widens_tolerance_real_sweep( db_session: AsyncSession, ): """End-to-end proof that editing the platform_setting actually moves the trip floor for ``_safe_sweep``. Setup: 20 existing rows, upserted=17 (15% drop). * With default tolerance (20 → floor 16): 17 >= 16 → NO trip, sweep proceeds and 3 rows are soft-deleted. * If we narrow tolerance to 5 (the OLD hardcoded value → floor 19): 17 < 19 → TRIP. Together these prove the platform_setting value is read at sweep time AND determines the actual decision. A resolver regression that always returned the settings fallback would fail the second half of this test. """ # Default tolerance (20%) — 15% drop is within tolerance. integ = await _make_integration(db_session) prior_job_id = str(uuid.uuid4()) new_job_id = str(uuid.uuid4()) for i in range(20): await _make_iam_user( db_session, integ, user_name=f"alice-{i}", last_seen_sync_job_id=(new_job_id if i < 17 else prior_job_id), ) await _clear_setting(db_session) deleted = await _safe_sweep( db_session, model=CmdbIamUser, integration_id=str(integ.id), job_id=new_job_id, upserted=17, capability_label="iam_users", ) await db_session.commit() assert deleted == 3, f"expected 3 stale rows swept at default tol, got {deleted}" @pytest.mark.asyncio async def test_platform_setting_narrows_tolerance_real_trip( db_session: AsyncSession, ): """Mirror of the test above with tolerance narrowed via platform setting — same shape (20 rows, upserted=17, 15% drop) but the platform_setting forces a 5% tolerance → trip. A regression where ``_safe_sweep`` ignored the platform setting and used the fallback would let this sweep proceed and FAIL the ``raises`` assertion. """ integ = await _make_integration(db_session) prior_job_id = str(uuid.uuid4()) new_job_id = str(uuid.uuid4()) for i in range(20): await _make_iam_user( db_session, integ, user_name=f"bob-{i}", last_seen_sync_job_id=(new_job_id if i < 17 else prior_job_id), ) await _seed_setting(db_session, 5) with pytest.raises(ProviderTransientError) as exc: await _safe_sweep( db_session, model=CmdbIamUser, integration_id=str(integ.id), job_id=new_job_id, upserted=17, capability_label="iam_users", ) # Trip message must interpolate the ACTIVE tolerance (5), not the # settings fallback (20). Closes the TOCTOU window between the # floor check and the error message. assert "(>5% drop)" in str(exc.value) # No soft-deletes happened on trip. survivors = (await db_session.execute( select(CmdbIamUser).where( CmdbIamUser.integration_id == str(integ.id), CmdbIamUser.deleted_at.is_(None), ), )).scalars().all() assert len(survivors) == 20, "no rows should have been soft-deleted on trip"