"""API tests for the reconciliation-fix endpoints on API Key assignments (PR4 of the「找得到 → 办得了」theme): * ``POST /apikeys/assignments/{id}/transfer-owner`` — admin-direct ownership transfer (re-homes a leaver-owned / wrong-owner key). * ``POST /apikeys/assignments/{id}/cost-rules/re-enable`` — inverse of the revoke-time auto-disable. Both mirror ``require_asset_admin`` and leave an audit row. """ from __future__ import annotations import uuid from decimal import Decimal import pytest from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.models import AuditLog from app.domains.apikey.models import ApiKeyAssignment, ApiKeyProvider from app.domains.apikey.service import STATUS_ACTIVE from app.domains.cmdb.models import CmdbCostAllocationRule from app.identity.person_models import IdentityPerson async def _person( db: AsyncSession, *, email: str, status: str = "active", ) -> IdentityPerson: p = IdentityPerson( id=uuid.uuid4(), source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", display_name=email.split("@")[0], work_email=email, employment_status=status, ) db.add(p) await db.flush() return p async def _provider(db: AsyncSession) -> ApiKeyProvider: p = ApiKeyProvider( id=uuid.uuid4(), name=f"prov-{uuid.uuid4().hex[:6]}", description="", is_active=True, sort_order=0, ) db.add(p) await db.flush() return p async def _assignment( db: AsyncSession, *, provider, owner, detail=None, ) -> ApiKeyAssignment: a = ApiKeyAssignment( id=uuid.uuid4(), provider_id=provider.id, assigned_to=owner.id, status=STATUS_ACTIVE, request_number=f"AK-{uuid.uuid4().hex[:8].upper()}", project_name="recon-fix-test", reason="recon fix tests", monthly_budget_usd=Decimal("100.00"), key_value=None, detail=detail or {}, ) db.add(a) await db.flush() return a @pytest.mark.asyncio async def test_transfer_owner_admin_flips_assignee( client: AsyncClient, db_session: AsyncSession, admin_headers, ): prov = await _provider(db_session) old = await _person(db_session, email="old-owner@test.com") new = await _person(db_session, email="new-owner@test.com") a = await _assignment(db_session, provider=prov, owner=old) await db_session.commit() resp = await client.post( f"/api/v1/apikeys/assignments/{a.id}/transfer-owner", json={"assigned_to": str(new.id)}, headers=admin_headers, ) assert resp.status_code == 200, resp.text assert resp.json()["data"]["assigned_to"] == str(new.id) await db_session.refresh(a) assert a.assigned_to == new.id assert a.transferred_from == old.id row = (await db_session.execute( select(AuditLog).where( AuditLog.action == "apikey.assignment.transfer_owner", ), )).scalars().first() assert row is not None assert row.detail["new_owner_id"] == str(new.id) @pytest.mark.asyncio async def test_transfer_owner_rejects_leaver_recipient( client: AsyncClient, db_session: AsyncSession, admin_headers, ): prov = await _provider(db_session) old = await _person(db_session, email="o2@test.com") leaver = await _person(db_session, email="leaver@test.com", status="left") a = await _assignment(db_session, provider=prov, owner=old) await db_session.commit() resp = await client.post( f"/api/v1/apikeys/assignments/{a.id}/transfer-owner", json={"assigned_to": str(leaver.id)}, headers=admin_headers, ) assert resp.status_code == 409 await db_session.refresh(a) assert a.assigned_to == old.id @pytest.mark.asyncio async def test_transfer_owner_employee_forbidden( client: AsyncClient, db_session: AsyncSession, employee_headers, ): prov = await _provider(db_session) old = await _person(db_session, email="o3@test.com") new = await _person(db_session, email="n3@test.com") a = await _assignment(db_session, provider=prov, owner=old) await db_session.commit() resp = await client.post( f"/api/v1/apikeys/assignments/{a.id}/transfer-owner", json={"assigned_to": str(new.id)}, headers=employee_headers, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_re_enable_cost_rules( client: AsyncClient, db_session: AsyncSession, admin_headers, ): prov = await _provider(db_session) owner = await _person(db_session, email="rule-owner@test.com") rule = CmdbCostAllocationRule( id=str(uuid.uuid4()), name="bedrock-rule", priority=10, match_provider="aws", match_tag_key="Owner", match_tag_value="rule-owner@test.com", assign_person_id=str(owner.id), enabled=False, # previously disabled on revoke description="autogrant rule", ) db_session.add(rule) await db_session.flush() a = await _assignment( db_session, provider=prov, owner=owner, detail={"bedrock": {"allocation_rule_id": str(rule.id)}}, ) await db_session.commit() resp = await client.post( f"/api/v1/apikeys/assignments/{a.id}/cost-rules/re-enable", headers=admin_headers, ) assert resp.status_code == 200, resp.text body = resp.json()["data"] assert body["count"] == 1 assert str(rule.id) in body["rules_re_enabled"] await db_session.refresh(rule) assert rule.enabled is True # Idempotent replay → already_enabled, count 0. resp2 = await client.post( f"/api/v1/apikeys/assignments/{a.id}/cost-rules/re-enable", headers=admin_headers, ) assert resp2.status_code == 200 assert resp2.json()["data"]["count"] == 0 row = (await db_session.execute( select(AuditLog).where( AuditLog.action == "apikey.cost_rules.re_enable", ), )).scalars().first() assert row is not None @pytest.mark.asyncio async def test_re_enable_cost_rules_employee_forbidden( client: AsyncClient, db_session: AsyncSession, employee_headers, ): prov = await _provider(db_session) owner = await _person(db_session, email="re-emp@test.com") a = await _assignment(db_session, provider=prov, owner=owner) await db_session.commit() resp = await client.post( f"/api/v1/apikeys/assignments/{a.id}/cost-rules/re-enable", headers=employee_headers, ) assert resp.status_code == 403