"""Tests for the Report Generator plugin — PDF support, presets, templates, RBAC.""" from __future__ import annotations import os import shutil import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession from app.core.db import close_engine, reset_engine_for_testing from app.core.service_container import get_container from app.main import create_app from app.plugins.builtins.permissions import PermissionsPlugin from app.plugins.builtins.report_generator import ReportGeneratorPlugin from app.plugins.registry import reset_registry_for_testing from app.services.plugin_service import reset_plugin_service_for_testing from tests.conftest import ( ORIGIN_HEADER, login_client, seed_tenant_and_users, ) REPORT_TEST_STORAGE = "/tmp/report_test" @pytest_asyncio.fixture async def report_app(engine: AsyncEngine, redis_client): """FastAPI app with Report Generator + Permissions plugins registered, installed, and activated.""" os.environ["REPORT_STORAGE_BASE"] = REPORT_TEST_STORAGE reset_engine_for_testing(engine) app = create_app() registry = reset_registry_for_testing() registry.initialize(engine, app) container = get_container() await container.initialize() registry.register_plugin(PermissionsPlugin()) registry.register_plugin(ReportGeneratorPlugin()) reset_plugin_service_for_testing(registry) _sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) async with _sf() as session: await registry.install(session, "permissions") await registry.activate(session, "permissions") await registry.install(session, "report_generator") await registry.activate(session, "report_generator") await session.commit() yield app await close_engine() if os.path.exists(REPORT_TEST_STORAGE): shutil.rmtree(REPORT_TEST_STORAGE, ignore_errors=True) @pytest_asyncio.fixture async def report_client(report_app) -> AsyncClient: transport = ASGITransport(app=report_app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c @pytest_asyncio.fixture async def report_authed_client( report_client: AsyncClient, db_session: AsyncSession ) -> tuple[AsyncClient, dict]: """Authenticated admin client with seeded data and report generator plugin activated.""" seed = await seed_tenant_and_users(db_session) login_resp = await report_client.post( "/api/v1/auth/login", json={"email": "admin@tenanta.com", "password": "TestPass123!"}, headers=ORIGIN_HEADER, ) assert login_resp.status_code == 200, f"Login failed: {login_resp.text}" csrf_token = login_resp.json().get("csrf_token", "") report_client.headers.update({"X-CSRF-Token": csrf_token}) return report_client, seed # ─── Tests ─── class TestReportPresets: """Test preset report listing and generation.""" async def test_list_presets(self, report_authed_client): """GET /presets returns all 5 preset templates.""" client, _ = report_authed_client resp = await client.get("/api/v1/reports/presets", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) assert len(data) == 5 keys = {item["key"] for item in data} assert keys == { "contact_list", "calendar_week", "calendar_month", "company_list", "audit_log", } # Each preset should have required fields for item in data: assert "name" in item assert "description" in item assert "icon" in item assert "output_formats" in item assert "pdf" in item["output_formats"] async def test_generate_preset_pdf(self, report_authed_client): """POST /presets/generate returns a PDF file for contact_list preset.""" client, _ = report_authed_client resp = await client.post( "/api/v1/reports/presets/generate", json={ "preset": "contact_list", "output_format": "pdf", "parameters": { "title": "Test Kontaktliste", "contacts": [ {"name": "Max Mustermann", "email": "max@test.com", "phone": "+49 123 456789", "type": "customer", "company": "Test GmbH"}, {"name": "Anna Schmidt", "email": "anna@test.com", "phone": "+49 987 654321", "type": "supplier", "company": "AG GmbH"}, ], }, }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200, f"Generate failed: {resp.text[:300]}" assert resp.headers["content-type"].startswith("application/pdf") assert "attachment" in resp.headers.get("content-disposition", "") # Verify it's a valid PDF (starts with %PDF) content = resp.content assert content[:5] == b"%PDF-" assert len(content) > 100 # Should have meaningful content async def test_generate_preset_csv(self, report_authed_client): """POST /presets/generate returns a CSV file for company_list preset.""" client, _ = report_authed_client resp = await client.post( "/api/v1/reports/presets/generate", json={ "preset": "company_list", "output_format": "csv", "parameters": { "companies": [ {"name": "Test GmbH", "address": "Teststr. 1", "zip": "12345", "city": "Berlin", "phone": "+49 123", "email": "info@test.de", "contact_person": "Max"}, ], }, }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200, f"Generate failed: {resp.text[:300]}" assert resp.headers["content-type"].startswith("text/csv") content = resp.content assert b"Test GmbH" in content assert b"Firmenname" in content class TestReportTemplates: """Test template CRUD and report generation with PDF output.""" async def test_create_and_generate_pdf_template(self, report_authed_client): """Create a template with output_format=pdf and generate a report (file download).""" client, _ = report_authed_client # Create a PDF template create_resp = await client.post( "/api/v1/reports/templates", json={ "name": "Custom PDF Report", "description": "A custom HTML template for PDF", "template_type": "jinja2", "content": "

{{ title }}

{{ message }}

", "output_format": "pdf", }, headers=ORIGIN_HEADER, ) assert create_resp.status_code == 201, f"Create failed: {create_resp.text}" template = create_resp.json() assert template["output_format"] == "pdf" template_id = template["id"] # Generate a report from this template — returns file directly gen_resp = await client.post( "/api/v1/reports/generate", json={ "template_id": template_id, "data": {"title": "Hello World", "message": "This is a test PDF."}, }, headers=ORIGIN_HEADER, ) assert gen_resp.status_code == 200, f"Generate failed: {gen_resp.text[:300]}" assert gen_resp.headers["content-type"].startswith("application/pdf") assert gen_resp.content[:5] == b"%PDF-" async def test_output_format_validation(self, report_authed_client): """Template creation rejects invalid output_format values.""" client, _ = report_authed_client resp = await client.post( "/api/v1/reports/templates", json={ "name": "Bad Format", "content": "test", "output_format": "invalid_format", }, headers=ORIGIN_HEADER, ) assert resp.status_code == 422 # Validation error class TestReportRBAC: """Test RBAC enforcement on report endpoints.""" async def test_unauthenticated_access_blocked(self, report_client): """Unauthenticated requests to reports endpoints are rejected.""" resp = await report_client.get("/api/v1/reports/presets", headers=ORIGIN_HEADER) assert resp.status_code == 401 async def test_viewer_cannot_manage_templates(self, report_authed_client, db_session): """Viewer role cannot create templates (requires manage_templates) and cannot generate (requires generate).""" client, seed = report_authed_client # Login as viewer viewer_resp = await client.post( "/api/v1/auth/login", json={"email": "viewer@tenanta.com", "password": "TestPass123!"}, headers=ORIGIN_HEADER, ) assert viewer_resp.status_code == 200 csrf_token = viewer_resp.json().get("csrf_token", "") client.headers.update({"X-CSRF-Token": csrf_token}) # Viewer does not have reports:read in legacy permissions → 403 on presets resp = await client.get("/api/v1/reports/presets", headers=ORIGIN_HEADER) assert resp.status_code == 403 # Viewer should NOT be able to create templates resp = await client.post( "/api/v1/reports/templates", json={"name": "Forbidden", "content": "test", "output_format": "pdf"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 403 # Viewer should NOT be able to generate preset reports resp = await client.post( "/api/v1/reports/presets/generate", json={"preset": "contact_list", "output_format": "pdf", "parameters": {}}, headers=ORIGIN_HEADER, ) assert resp.status_code == 403