"""Tests for ``GET /api/v1/audit/resources/history`` — per-resource lifecycle timeline endpoint added 2026-05-19. Covers: - Resource-type-aware role gating (Cloud Admin / Asset Admin / Auditor). - Union of ``audit_logs`` with ``cmdb_network_owner_history`` for cloud network resources (DNS / domain / CDN / LB). - Honest pagination ``total`` across the merge. - 403 does not leak which resource_type was the offender. - Bad/empty input → 400 not 403. - Server path unions owner_history via the ``cloud_server`` mapping (added 2026-07-21); asset / user_permission paths still skip the union (no false positives). """ from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.audit.models import AuditLog from app.audit.service import AuditService async def _make_audit_log( db: AsyncSession, *, actor_id: uuid.UUID, action: str, resource_type: str, resource_id: str, created_at: datetime | None = None, ) -> AuditLog: svc = AuditService(db) row = await svc.log( trace_id=str(uuid.uuid4()), actor_type="user", actor_user_id=actor_id, action=action, status="success", resource_type=resource_type, resource_id=resource_id, detail={"note": action}, ) if created_at is not None: # Pre-launch fixtures need controllable ordering — override # the server_default after the flush. row.created_at = created_at await db.flush() await db.commit() return row @pytest.mark.asyncio async def test_admin_can_read_dns_record_history(client, admin_user, db_session): """Global admin can read a DNS record's audit timeline.""" admin, token = admin_user record_id = str(uuid.uuid4()) await _make_audit_log( db_session, actor_id=admin.id, action="cmdb.dns.create", resource_type="cmdb_dns_record", resource_id=record_id, ) resp = await client.get( "/api/v1/audit/resources/history", params={"resource_type": "cmdb_dns_record", "resource_id": record_id}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["success"] is True assert body["meta"]["total"] >= 1 actions = [r["action"] for r in body["data"]] assert "cmdb.dns.create" in actions @pytest.mark.asyncio async def test_dns_history_unions_owner_history( client, admin_user, db_session, ): """Owner transfers live in cmdb_network_owner_history, NOT audit_logs. The lifecycle endpoint must merge the two so 'every change to this DNS record' actually surfaces ownership moves. """ from app.domains.cmdb.models import CmdbNetworkOwnerHistory admin, token = admin_user record_id = str(uuid.uuid4()) # One real audit row + one owner_history row, owner_history MORE # recent. The merged response should put owner_history first. await _make_audit_log( db_session, actor_id=admin.id, action="cmdb.dns.create", resource_type="cmdb_dns_record", resource_id=record_id, created_at=datetime.now(UTC) - timedelta(minutes=10), ) db_session.add(CmdbNetworkOwnerHistory( resource_type="dns_record", resource_id=record_id, previous_owner_person_id=None, new_owner_person_id=str(admin.id), actor_person_id=str(admin.id), change_reason="admin_patch", trace_id=str(uuid.uuid4())[:40], context={"previous_label": None, "new_label": "Owner A"}, created_at=datetime.now(UTC), )) await db_session.commit() resp = await client.get( "/api/v1/audit/resources/history", params={"resource_type": "cmdb_dns_record", "resource_id": record_id}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["meta"]["total"] == 2 actions = [r["action"] for r in body["data"]] # Synthetic owner_history row uses ``cmdb.owner_history.`` # namespace so a reader can distinguish merged-from-owner_history # from native audit emissions. assert "cmdb.owner_history.admin_patch" in actions assert "cmdb.dns.create" in actions # Sort is descending by created_at — owner_history is newer. assert actions[0] == "cmdb.owner_history.admin_patch" # Synthetic row exposes a normalised ``cmdb_dns_record`` # resource_type so the consumer doesn't see the owner_history # table's bare-name schema detail. owner_row = next(r for r in body["data"] if r["action"].startswith("cmdb.owner_history.")) assert owner_row["resource_type"] == "cmdb_dns_record" assert owner_row["detail"]["change_reason"] == "admin_patch" assert owner_row["detail"]["new_owner_label"] == "Owner A" @pytest.mark.asyncio async def test_server_history_unions_cloud_server_owner_history( client, admin_user, db_session, ): """Server owner transfers are written to cmdb_network_owner_history with resource_type='cloud_server' (admin PATCH / batch / self-service / offboarding force-transfer). The lifecycle endpoint must merge them into the cmdb_server timeline — this map entry was missing until 2026-07-21 and every server owner transfer was silently absent. Rows with a different (bogus) type string must still be excluded.""" from app.domains.cmdb.models import CmdbNetworkOwnerHistory admin, token = admin_user server_id = str(uuid.uuid4()) await _make_audit_log( db_session, actor_id=admin.id, action="cmdb.server.annotate", resource_type="cmdb_server", resource_id=server_id, created_at=datetime.now(UTC) - timedelta(minutes=10), ) db_session.add(CmdbNetworkOwnerHistory( resource_type="cloud_server", resource_id=server_id, previous_owner_person_id=None, new_owner_person_id=str(admin.id), actor_person_id=str(admin.id), change_reason="self_service_transfer", trace_id=str(uuid.uuid4())[:40], context={"previous_label": None, "new_label": "Owner B"}, created_at=datetime.now(UTC), )) # A row with an unmapped type string must NOT be merged. db_session.add(CmdbNetworkOwnerHistory( resource_type="server", # bogus — not a canonical type resource_id=server_id, new_owner_person_id=str(admin.id), actor_person_id=str(admin.id), change_reason="admin_patch", context={}, )) await db_session.commit() resp = await client.get( "/api/v1/audit/resources/history", params={"resource_type": "cmdb_server", "resource_id": server_id}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["meta"]["total"] == 2 actions = [r["action"] for r in body["data"]] assert actions[0] == "cmdb.owner_history.self_service_transfer" assert "cmdb.server.annotate" in actions owner_row = body["data"][0] assert owner_row["resource_type"] == "cmdb_server" assert owner_row["detail"]["new_owner_label"] == "Owner B" @pytest.mark.asyncio async def test_asset_admin_can_read_asset_history( client, asset_admin_user, admin_user, db_session, ): """Asset Admin (NOT global_admin, NOT auditor) sees asset history without escalation to the global ``/audit/logs`` audit-admin gate. """ asset_admin, asset_token = asset_admin_user admin, _ = admin_user asset_id = str(uuid.uuid4()) await _make_audit_log( db_session, actor_id=admin.id, action="asset.claim", resource_type="asset", resource_id=asset_id, ) resp = await client.get( "/api/v1/audit/resources/history", params={"resource_type": "asset", "resource_id": asset_id}, headers={"Authorization": f"Bearer {asset_token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["meta"]["total"] == 1 @pytest.mark.asyncio async def test_asset_admin_cannot_read_user_permission_history( client, asset_admin_user, admin_user, db_session, ): """Resource-type-aware gate: Asset Admin is NOT entitled to read permission-grant lifecycle. Endpoint returns 403 with a generic message that does not echo back the offending resource_type. """ asset_admin, asset_token = asset_admin_user admin, _ = admin_user grant_id = str(uuid.uuid4()) await _make_audit_log( db_session, actor_id=admin.id, action="permission.admin_granted", resource_type="user_permission", resource_id=grant_id, ) resp = await client.get( "/api/v1/audit/resources/history", params={"resource_type": "user_permission", "resource_id": grant_id}, headers={"Authorization": f"Bearer {asset_token}"}, ) assert resp.status_code == 403 # Generic message — anti-enumeration. Resource type should NOT # appear in the error body. err_body = resp.json() error_messages = " ".join( e.get("message", "") for e in err_body.get("errors", []) ) assert "user_permission" not in error_messages @pytest.mark.asyncio async def test_employee_cannot_read_any_history( client, employee_user, admin_user, db_session, ): """Plain employee (no admin role) is blocked from every resource_type — the endpoint is admin/auditor/cloud_admin/ asset_admin only; employees use the in-page operation log on their own /me views (separate surface).""" employee, emp_token = employee_user admin, _ = admin_user asset_id = str(uuid.uuid4()) await _make_audit_log( db_session, actor_id=admin.id, action="asset.claim", resource_type="asset", resource_id=asset_id, ) resp = await client.get( "/api/v1/audit/resources/history", params={"resource_type": "asset", "resource_id": asset_id}, headers={"Authorization": f"Bearer {emp_token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_empty_comma_split_returns_validation_error(client, admin_user): """Whitespace/comma-only resource_type after FastAPI's min_length=1 must return 422 (validation, via ``ValidationError``), NOT 403 (authz). The status code matters: a 403 here would make an admin think they lack permission when they actually fat-fingered the query. """ _, token = admin_user resp = await client.get( "/api/v1/audit/resources/history", params={"resource_type": " , ,", "resource_id": str(uuid.uuid4())}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_unknown_resource_type_falls_back_to_audit_admin_gate( client, asset_admin_user, ): """A never-before-seen resource_type defaults to the strict audit-admin (global_admin / auditor / management) gate, NOT to asset_admin or cloud_admin. Asset Admin trying a mystery type gets 403 — explicit-listing required to broaden access. """ _, asset_token = asset_admin_user resp = await client.get( "/api/v1/audit/resources/history", params={ "resource_type": "future_unmapped_type", "resource_id": str(uuid.uuid4()), }, headers={"Authorization": f"Bearer {asset_token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_unknown_resource_id_returns_200_with_empty(client, admin_user): """An unknown resource_id under an allowed type returns 200 with empty data — same semantics as the resource's own list endpoint (where unknown ids 404 from the resource API, but the AUDIT surface intentionally returns empty: there is no audit row, therefore nothing to render). This is documented behaviour: the endpoint does NOT confirm/deny resource existence, only existence of audit history. """ _, token = admin_user resp = await client.get( "/api/v1/audit/resources/history", params={ "resource_type": "cmdb_dns_record", "resource_id": str(uuid.uuid4()), }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 body = resp.json() assert body["data"] == [] assert body["meta"]["total"] == 0