"""API tests for GET /audit/export β€” upgraded from CSV to the unified XLSX export primitive (app/core/exporting.py). Pins the grandfathered behaviours that must survive the upgrade: the at-least-one-scoping-filter guard (AUDIT_EXPORT_SCOPE_REQUIRED, 400), the ``require_audit_access`` RBAC gate, and the ``audit.export`` audit action name β€” plus the new XLSX contract (content-type, X-Export-* headers, two-row header, JSON-stringified detail cells). """ from __future__ import annotations import io import uuid import pytest from httpx import AsyncClient from openpyxl import load_workbook from sqlalchemy import select from app.audit.models import AuditLog XLSX_CT = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" async def _seed_log(db, *, action: str, detail: dict | None = None) -> AuditLog: log = AuditLog( id=uuid.uuid4(), trace_id=uuid.uuid4(), actor_type="user", actor_user_id=uuid.uuid4(), action=action, resource_type="export_seed", resource_id="seed-1", status="success", detail=detail, ip_address="10.0.0.1", ) db.add(log) await db.flush() return log @pytest.mark.asyncio async def test_export_returns_xlsx_with_headers_and_audit( client: AsyncClient, db_session, admin_headers, ): action = f"test.export.{uuid.uuid4().hex[:8]}" await _seed_log(db_session, action=action, detail={"reason": "seeded"}) await db_session.commit() resp = await client.get( "/api/v1/audit/export", params={"action": action}, headers=admin_headers, ) assert resp.status_code == 200, resp.text assert resp.headers["content-type"].startswith(XLSX_CT) assert resp.headers["x-export-truncated"] == "false" assert int(resp.headers["x-export-returned"]) == 1 assert "audit_logs_" in resp.headers["content-disposition"] assert resp.headers["content-disposition"].endswith('.xlsx"') wb = load_workbook(io.BytesIO(resp.content)) ws = wb.active # two-row header convention: labels then machine keys assert ws.cell(row=1, column=1).value == "ζ—Άι—΄" assert ws.cell(row=2, column=1).value == "created_at" assert ws.cell(row=3, column=2).value == action # detail dict JSON-stringified by the primitive's to_cell detail_col = next( c for c in range(1, ws.max_column + 1) if ws.cell(row=2, column=c).value == "detail" ) assert "seeded" in (ws.cell(row=3, column=detail_col).value or "") # grandfathered audit action name on the export's own audit row row = ( await db_session.execute( select(AuditLog).where( AuditLog.action == "audit.export", ).order_by(AuditLog.created_at.desc()), ) ).scalars().first() assert row is not None assert row.resource_type == "audit_logs" assert row.detail["filters"]["action"] == action assert row.detail["filters"]["page_size"] == 5000 @pytest.mark.asyncio async def test_export_requires_at_least_one_filter( client: AsyncClient, db_session, admin_headers, ): resp = await client.get("/api/v1/audit/export", headers=admin_headers) assert resp.status_code == 400 assert "AUDIT_EXPORT_SCOPE_REQUIRED" in resp.text @pytest.mark.asyncio async def test_export_page_size_capped_at_5000( client: AsyncClient, db_session, admin_headers, ): resp = await client.get( "/api/v1/audit/export", params={"action": "whatever", "page_size": 5001}, headers=admin_headers, ) assert resp.status_code == 422 @pytest.mark.asyncio async def test_employee_cannot_export( client: AsyncClient, db_session, employee_headers, ): resp = await client.get( "/api/v1/audit/export", params={"action": "anything"}, headers=employee_headers, ) assert resp.status_code == 403