"""Bundle service CRUD + apply-to-person (PR-2 2026-06-01).""" from __future__ import annotations from decimal import Decimal from uuid import uuid4 import pytest from sqlalchemy import select from app.domains.asset.models import ( AssetSoftwareCatalog, SoftwareLicensePlan, ) from app.domains.provisioning.bundle_service import BundleService from app.domains.provisioning.models import ( ACTION_GRANT, SOURCE_ONBOARDING, STATUS_PENDING, ProvisioningBundle, ProvisioningBundleMember, ProvisioningTask, ProvisionPolicy, ) from app.identity.person_models import IdentityPerson def _mk_person(work_email: str) -> IdentityPerson: return IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id=f"sf-{uuid4().hex[:6]}", work_email=work_email, display_name=work_email.split("@")[0], employment_status="active", ) async def _mk_software(db, name: str) -> AssetSoftwareCatalog: catalog = AssetSoftwareCatalog( id=uuid4(), name=name, offboarding_policy="auto_revoke", category="collaboration", ) db.add(catalog) await db.flush() return catalog async def _mk_policy( db, software: AssetSoftwareCatalog, *, department: str | None = None, ) -> ProvisionPolicy: """Build a seat-targeted policy for a fresh license plan on this software. The bundle apply path filters policies through ``_build_preview_items`` which only recognises ``target_type='seat'`` / ``'permission'`` — not ``'software'``. """ plan = SoftwareLicensePlan( id=uuid4(), software_id=software.id, name="Standard", price=Decimal("0"), billing_cycle="monthly", over_quota_policy="block", currency="USD", ) db.add(plan) await db.flush() policy = ProvisionPolicy( id=uuid4(), department_name=department, target_type="seat", target_id=plan.id, is_active=True, ) db.add(policy) await db.flush() return policy @pytest.mark.asyncio async def test_create_and_list_bundles(db_session): svc = BundleService(db_session) b = await svc.create_bundle( name="SDE Test Pack", description="x", target_role_hint="SDE", trace_id="t1", ) await db_session.commit() assert b.name == "SDE Test Pack" rows = await svc.list_bundles() names = {b.name for b, _ in rows} assert "SDE Test Pack" in names @pytest.mark.asyncio async def test_create_bundle_duplicate_name_rejected(db_session): svc = BundleService(db_session) await svc.create_bundle(name="DupPack", description=None, target_role_hint=None, trace_id="t1") await db_session.commit() with pytest.raises(Exception): await svc.create_bundle( name="DupPack", description=None, target_role_hint=None, trace_id="t2", ) @pytest.mark.asyncio async def test_add_and_remove_members(db_session): sw = await _mk_software(db_session, "TestApp") policy = await _mk_policy(db_session, sw) svc = BundleService(db_session) bundle = await svc.create_bundle( name="MembersPack", description=None, target_role_hint=None, trace_id="t", ) await db_session.commit() await svc.add_member(bundle.id, policy.id, trace_id="t") # idempotent re-add await svc.add_member(bundle.id, policy.id, trace_id="t") await db_session.commit() rows = ( await db_session.execute( select(ProvisioningBundleMember).where( ProvisioningBundleMember.bundle_id == bundle.id ) ) ).scalars().all() assert len(rows) == 1 await svc.remove_member(bundle.id, policy.id, trace_id="t") await db_session.commit() rows = ( await db_session.execute( select(ProvisioningBundleMember).where( ProvisioningBundleMember.bundle_id == bundle.id ) ) ).scalars().all() assert rows == [] @pytest.mark.asyncio async def test_apply_bundle_creates_tasks_for_uncovered_targets(db_session): sw1 = await _mk_software(db_session, "AppOne") sw2 = await _mk_software(db_session, "AppTwo") p1 = await _mk_policy(db_session, sw1) p2 = await _mk_policy(db_session, sw2) person = _mk_person("appy@x.com") db_session.add(person) await db_session.flush() svc = BundleService(db_session) bundle = await svc.create_bundle( name="ApplyPack", description=None, target_role_hint=None, trace_id="t", ) await svc.add_member(bundle.id, p1.id, trace_id="t") await svc.add_member(bundle.id, p2.id, trace_id="t") await db_session.commit() result = await svc.apply_to_person(bundle.id, person.id, trace_id="t") await db_session.commit() assert len(result["created_task_ids"]) == 2 assert result["skipped"] == 0 tasks = ( await db_session.execute( select(ProvisioningTask).where( ProvisioningTask.user_id == person.id, ) ) ).scalars().all() assert len(tasks) == 2 assert all(t.source == "bundle_apply" for t in tasks) assert all(t.status == STATUS_PENDING for t in tasks) assert all(t.action == ACTION_GRANT for t in tasks) @pytest.mark.asyncio async def test_apply_bundle_skips_already_pending_targets(db_session): sw = await _mk_software(db_session, "AppSkip") policy = await _mk_policy(db_session, sw) person = _mk_person("skipy@x.com") db_session.add(person) await db_session.flush() # Pre-existing pending task on the SAME target — bundle apply # should skip it (dedup). db_session.add( ProvisioningTask( id=uuid4(), user_id=person.id, software_id=sw.id, action=ACTION_GRANT, target_type=policy.target_type, target_id=policy.target_id, source=SOURCE_ONBOARDING, execution_mode="auto_api", status=STATUS_PENDING, ) ) await db_session.flush() svc = BundleService(db_session) bundle = await svc.create_bundle( name="SkipPack", description=None, target_role_hint=None, trace_id="t", ) await svc.add_member(bundle.id, policy.id, trace_id="t") await db_session.commit() result = await svc.apply_to_person(bundle.id, person.id, trace_id="t") await db_session.commit() assert len(result["created_task_ids"]) == 0 assert result["skipped"] == 1 @pytest.mark.asyncio async def test_apply_inactive_bundle_raises(db_session): person = _mk_person("inactive@x.com") db_session.add(person) await db_session.flush() svc = BundleService(db_session) bundle = await svc.create_bundle( name="InactivePack", description=None, target_role_hint=None, trace_id="t", ) await svc.update_bundle(bundle.id, is_active=False, trace_id="t") await db_session.commit() with pytest.raises(Exception): await svc.apply_to_person(bundle.id, person.id, trace_id="t") @pytest.mark.asyncio async def test_delete_bundle_leaves_policies_intact(db_session): sw = await _mk_software(db_session, "AppDeletePack") policy = await _mk_policy(db_session, sw) svc = BundleService(db_session) bundle = await svc.create_bundle( name="DeletePack", description=None, target_role_hint=None, trace_id="t", ) await svc.add_member(bundle.id, policy.id, trace_id="t") await db_session.commit() await svc.delete_bundle(bundle.id, trace_id="t") await db_session.commit() # Bundle gone; policy survives. assert ( await db_session.get(ProvisioningBundle, bundle.id) ) is None assert ( await db_session.get(ProvisionPolicy, policy.id) ) is not None