"""Batch handover / revoke for domain registrations. POST /api/v1/domain-registrations/batch-handover POST /api/v1/domain-registrations/batch-revoke Contract (shared batch-endpoint convention): - ids capped at 500 (Pydantic) → 422 over the cap; - per-row SAVEPOINT isolation — one bad row only fails that row; - terminal rows (rejected / cancelled / revoked) and rows already in the target state → ``skipped``; - one aggregated audit row per call. """ 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.audit.models import AuditLog from app.domains.domain_registration.models import ( DOMAIN_REG_STATUS_APPROVED, DOMAIN_REG_STATUS_PENDING, DOMAIN_REG_STATUS_REVOKED, DomainRegistration, ) from app.identity.person_models import IdentityPerson async def _make_person(db: AsyncSession, email: str) -> IdentityPerson: person = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=email.split("@")[0], employment_status="active", source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:10]}", ) db.add(person) await db.flush() return person async def _make_reg( db: AsyncSession, person_id: uuid.UUID, *, status: str = DOMAIN_REG_STATUS_APPROVED, domain: str | None = None, ) -> DomainRegistration: row = DomainRegistration( id=str(uuid.uuid4()), person_id=str(person_id), domain_name=domain or f"{uuid.uuid4().hex[:10]}.example.com", purpose="batch endpoint test", status=status, granted_at=( datetime.now(UTC) if status == DOMAIN_REG_STATUS_APPROVED else None ), ) db.add(row) await db.flush() return row @pytest.mark.asyncio async def test_batch_handover_all_success(client, admin_user, db_session): admin, token = admin_user holder = await _make_person(db_session, f"holder-{uuid.uuid4().hex[:6]}@test.com") successor = await _make_person(db_session, f"succ-{uuid.uuid4().hex[:6]}@test.com") reg_a = await _make_reg(db_session, holder.id) reg_b = await _make_reg(db_session, holder.id) await db_session.commit() resp = await client.post( "/api/v1/domain-registrations/batch-handover", json={ "reg_ids": [reg_a.id, reg_b.id], "successor_person_id": str(successor.id), }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["succeeded"] == 2 assert data["skipped"] == 0 assert data["failed"] == 0 assert {r["status"] for r in data["results"]} == {"succeeded"} for reg_id in (reg_a.id, reg_b.id): row = await db_session.get(DomainRegistration, reg_id) assert row.person_id == str(successor.id) assert row.handover_from_person_id == str(holder.id) assert row.handover_actor_id == str(admin.id) @pytest.mark.asyncio async def test_batch_handover_mixed_rows(client, admin_user, db_session): """Terminal rows + already-handed-over rows skip; missing ids fail; the good row still goes through (SAVEPOINT isolation).""" _, token = admin_user holder = await _make_person(db_session, f"holder-{uuid.uuid4().hex[:6]}@test.com") successor = await _make_person(db_session, f"succ-{uuid.uuid4().hex[:6]}@test.com") good = await _make_reg(db_session, holder.id) revoked = await _make_reg(db_session, holder.id, status=DOMAIN_REG_STATUS_REVOKED) already_theirs = await _make_reg(db_session, successor.id) missing_id = str(uuid.uuid4()) await db_session.commit() resp = await client.post( "/api/v1/domain-registrations/batch-handover", json={ "reg_ids": [good.id, revoked.id, already_theirs.id, missing_id], "successor_person_id": str(successor.id), }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["succeeded"] == 1 assert data["skipped"] == 2 assert data["failed"] == 1 by_id = {r["id"]: r for r in data["results"]} assert by_id[good.id]["status"] == "succeeded" assert by_id[revoked.id]["status"] == "skipped" assert by_id[already_theirs.id]["status"] == "skipped" assert by_id[missing_id]["status"] == "failed" assert by_id[missing_id]["error"] == "not_found" # Aggregated audit row. audit_row = ( await db_session.execute( select(AuditLog) .where(AuditLog.action == "domain_registration.batch_handover") .order_by(AuditLog.created_at.desc()) ) ).scalars().first() assert audit_row is not None assert audit_row.detail["total"] == 4 assert audit_row.detail["succeeded"] == 1 assert audit_row.detail["skipped"] == 2 assert audit_row.detail["failed"] == 1 assert good.id in audit_row.detail["ids"] @pytest.mark.asyncio async def test_batch_revoke_mixed_rows_and_audit(client, admin_user, db_session): _, token = admin_user holder = await _make_person(db_session, f"holder-{uuid.uuid4().hex[:6]}@test.com") good = await _make_reg(db_session, holder.id) already_revoked = await _make_reg( db_session, holder.id, status=DOMAIN_REG_STATUS_REVOKED, ) pending = await _make_reg(db_session, holder.id, status=DOMAIN_REG_STATUS_PENDING) missing_id = str(uuid.uuid4()) await db_session.commit() resp = await client.post( "/api/v1/domain-registrations/batch-revoke", json={ "reg_ids": [good.id, already_revoked.id, pending.id, missing_id], "reason": "batch test cleanup", }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["succeeded"] == 1 assert data["skipped"] == 1 assert data["failed"] == 2 by_id = {r["id"]: r for r in data["results"]} assert by_id[good.id]["status"] == "succeeded" assert by_id[already_revoked.id]["status"] == "skipped" # A pending row is not terminal and not revocable — failed, not skipped. assert by_id[pending.id]["status"] == "failed" assert by_id[pending.id]["error"] == "DOMAIN_REG_NOT_REVOKABLE" assert by_id[missing_id]["status"] == "failed" row = await db_session.get(DomainRegistration, good.id) assert row.status == DOMAIN_REG_STATUS_REVOKED assert row.revoke_reason == "batch test cleanup" # The pending row was untouched (its SAVEPOINT rolled back). pending_row = await db_session.get(DomainRegistration, pending.id) assert pending_row.status == DOMAIN_REG_STATUS_PENDING audit_row = ( await db_session.execute( select(AuditLog) .where(AuditLog.action == "domain_registration.batch_revoke") .order_by(AuditLog.created_at.desc()) ) ).scalars().first() assert audit_row is not None assert audit_row.detail["total"] == 4 assert audit_row.detail["succeeded"] == 1 assert audit_row.detail["reason"] == "batch test cleanup" @pytest.mark.asyncio @pytest.mark.parametrize( ("path", "service_method"), [ ("/api/v1/domain-registrations/batch-handover", "handover"), ("/api/v1/domain-registrations/batch-revoke", "revoke"), ], ) async def test_batch_unexpected_error_returns_stable_code( client, admin_user, db_session, monkeypatch, caplog, path, service_method, ): from app.domains.domain_registration.service import DomainRegistrationService secret = "token=domain-secret at /srv/private/domain.py:88" async def _boom(*args, **kwargs): raise RuntimeError(secret) monkeypatch.setattr(DomainRegistrationService, service_method, _boom) holder = await _make_person(db_session, f"holder-{uuid.uuid4().hex[:6]}@test.com") successor = await _make_person(db_session, f"successor-{uuid.uuid4().hex[:6]}@test.com") row = await _make_reg(db_session, holder.id) await db_session.commit() _, token = admin_user payload = {"reg_ids": [row.id]} if service_method == "handover": payload["successor_person_id"] = str(successor.id) else: payload["reason"] = "test" response = await client.post( path, json=payload, headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 assert response.json()["data"]["results"][0] == { "id": row.id, "status": "failed", "error": "INTERNAL_ERROR", } assert secret not in response.text assert secret not in caplog.text @pytest.mark.asyncio async def test_batch_endpoints_employee_403(client, employee_user, db_session): employee, token = employee_user resp = await client.post( "/api/v1/domain-registrations/batch-handover", json={ "reg_ids": [str(uuid.uuid4())], "successor_person_id": str(employee.id), }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 resp = await client.post( "/api/v1/domain-registrations/batch-revoke", json={"reg_ids": [str(uuid.uuid4())]}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_batch_revoke_over_cap_422(client, admin_user): _, token = admin_user resp = await client.post( "/api/v1/domain-registrations/batch-revoke", json={"reg_ids": [str(uuid.uuid4()) for _ in range(501)]}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 422