"""PDF security + correctness tests (#7/#9/#13/#33). Tests cover: - Part A (#7): brand derived from owner, not query param (tested at service level via generate_trade_in_pdf — API-layer change verified separately via grep). - Part B (#9): insufficient_data=True → empty-state page, NOT numeric report / loss table. - Part C (#8): pdf_disclaimer / logo_url from brand object rendered on cover. - Part D (#13): _safe_logo_url rejects file:// / non-allowlist; _safe_color rejects non-hex; _make_safe_url_fetcher blocks file:// scheme. - Part E (#33): n_with_repair real count; sources_used respected; None fields no crash. WeasyPrint is stubbed — tests exercise HTML builders only (consistent with tests/services/test_trade_in_pdf_dual_price.py pattern). """ from __future__ import annotations import os import sys from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, patch from uuid import uuid4 os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) import pytest # noqa: E402 from app.schemas.trade_in import AggregatedEstimate, AnalogLot # noqa: E402 from app.services.brand import Brand # noqa: E402 from app.services.exporters import trade_in_pdf as mod # noqa: E402 # ── Fixtures ───────────────────────────────────────────────────────────────── _GENERIC = Brand( slug="generic", name="Trade-In", logo_url=None, primary_color="#1d4ed8", accent_color="#f59e0b", footer_text=None, pdf_disclaimer=None, ) _PRAKTIKA = Brand( slug="praktika", name="Практика", logo_url="https://gk-praktika.ru/images/logo/logo-pink.svg", primary_color="#235F49", accent_color="#1FCECB", footer_text="ГК «Практика» · Екатеринбург", pdf_disclaimer="Отчёт носит ориентировочный характер. ГК Практика, 2026.", ) _SNAPSHOT = { "address": "Екатеринбург, ул. Ленина, 1", "area_m2": 50.0, "rooms": 2, "floor": 3, "total_floors": 9, "year_built": 2010, "house_type": "panel", "repair_state": "standard", "has_balcony": True, } def _estimate(**overrides) -> AggregatedEstimate: base = dict( estimate_id=uuid4(), median_price_rub=10_000_000, range_low_rub=9_000_000, range_high_rub=11_000_000, median_price_per_m2=200_000, confidence="high", n_analogs=15, period_months=24, analogs=[], actual_deals=[], expires_at=datetime.now(UTC) + timedelta(days=30), ) base.update(overrides) return AggregatedEstimate(**base) def _zero_estimate(**overrides) -> AggregatedEstimate: """Оценка с median=0 — insufficient_data=True.""" base = dict( estimate_id=uuid4(), median_price_rub=0, range_low_rub=0, range_high_rub=0, median_price_per_m2=0, confidence="low", n_analogs=0, period_months=24, analogs=[], actual_deals=[], expires_at=datetime.now(UTC) + timedelta(days=30), ) base.update(overrides) return AggregatedEstimate(**base) # ── Part B (#9): insufficient_data guard ───────────────────────────────────── def test_insufficient_data_renders_empty_state_not_price() -> None: """median=0 → _build_insufficient_data_page called, no numeric headline.""" est = _zero_estimate() assert est.insufficient_data is True html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC) assert "Недостаточно данных" in html # no fabricated price / loss table assert "млн. руб." not in html assert "Общие финансовые потери" not in html def test_insufficient_data_page_contains_address_and_report_num() -> None: est = _zero_estimate() html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC) assert "Ленина" in html # address # #pdf-honesty: № отчёта city-aware — у оценки без target_address город не распознан # → нейтральный префикс «МЕРА» (раньше был захардкожен «EKБ» для любого объекта). assert "МЕРА-" in html assert "EKБ-" not in html # старый mixed-script хардкод убран def test_generate_pdf_insufficient_skips_offer_page() -> None: """generate_trade_in_pdf с insufficient_data → offer page (loss table) не рендерится.""" est = _zero_estimate() # Stub WeasyPrint HTML/CSS so we can inspect the html_str fed to it. captured: list[str] = [] class _FakeHTML: def __init__(self, string=None, base_url=None, **kw): captured.append(string or "") def write_pdf(self, stylesheets=None, url_fetcher=None, font_config=None): return b"%PDF-fake" class _FakeCSS: def __init__(self, string=None, font_config=None, url_fetcher=None): pass with patch.dict(sys.modules, {"weasyprint": MagicMock(HTML=_FakeHTML, CSS=_FakeCSS)}): import app.services.exporters.trade_in_pdf as _mod _mod.generate_trade_in_pdf(est, _SNAPSHOT, brand=_GENERIC) assert captured, "HTML was never passed to WeasyPrint mock" html = captured[0] assert "Недостаточно данных" in html assert "Общие финансовые потери" not in html assert "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ" not in html def test_generate_pdf_normal_estimate_has_offer_page() -> None: """Полная оценка → offer page присутствует.""" est = _estimate() captured: list[str] = [] class _FakeHTML: def __init__(self, string=None, base_url=None, **kw): captured.append(string or "") def write_pdf(self, stylesheets=None, url_fetcher=None, font_config=None): return b"%PDF-fake" class _FakeCSS: def __init__(self, string=None, font_config=None, url_fetcher=None): pass with patch.dict(sys.modules, {"weasyprint": MagicMock(HTML=_FakeHTML, CSS=_FakeCSS)}): import app.services.exporters.trade_in_pdf as _mod _mod.generate_trade_in_pdf(est, _SNAPSHOT, brand=_GENERIC) assert captured html = captured[0] assert "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ" in html # ── Part C (#8): disclaimer + logo_url in cover ────────────────────────────── def test_cover_renders_pdf_disclaimer() -> None: est = _estimate() html = mod._build_cover(est, _SNAPSHOT, _PRAKTIKA) assert "Отчёт носит ориентировочный характер" in html def test_cover_disclaimer_absent_when_none() -> None: est = _estimate() html = mod._build_cover(est, _SNAPSHOT, _GENERIC) # No stray disclaimer block assert "ориентировочный характер" not in html def test_cover_renders_logo_img_when_valid_url() -> None: # #4-pages-fix: _page_header больше не встроен в _build_cover — он рендерится # ОДИН раз в generate_trade_in_pdf как running-header (position:running(...), # печатается WeasyPrint'ом в @top-center margin-box на каждой странице), см. # trade_in_pdf.py::generate_trade_in_pdf. Логотип/wordmark проверяем напрямую # через _page_header, не через _build_cover (симметрично уже существовавшему # паттерну в test_cover_falls_back_to_name_when_logo_none ниже). header_html = mod._page_header(_PRAKTIKA, "EKБ-0001-0000000001", datetime.now(UTC).date()) assert " None: # Logo-specific check via _page_header directly — не проверяем отсутствие во # всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как # matplotlib SVG (см. _price_range_chart_svg), не связанные с логотипом бренда. header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date()) assert "TRADE-IN" in header_html.upper() assert " None: url = "https://gk-praktika.ru/images/logo.svg" assert mod._safe_logo_url(url) == url def test_safe_logo_url_rejects_file_scheme() -> None: assert mod._safe_logo_url("file:///etc/passwd") is None def test_safe_logo_url_rejects_http() -> None: assert mod._safe_logo_url("http://gk-praktika.ru/logo.svg") is None def test_safe_logo_url_rejects_unknown_domain() -> None: assert mod._safe_logo_url("https://evil.com/logo.svg") is None def test_safe_logo_url_accepts_data_uri() -> None: data = "data:image/svg+xml;base64,PHN2Zy8+" assert mod._safe_logo_url(data) == data def test_safe_logo_url_none_input() -> None: assert mod._safe_logo_url(None) is None # ── Part D (#13): _safe_color ───────────────────────────────────────────────── def test_safe_color_accepts_valid_hex() -> None: assert mod._safe_color("#1d4ed8", "#000000") == "#1d4ed8" assert mod._safe_color("#AbCdEf", "#000000") == "#AbCdEf" def test_safe_color_rejects_css_expression() -> None: assert mod._safe_color("expression(alert(1))", "#1d4ed8") == "#1d4ed8" def test_safe_color_rejects_short_hex() -> None: # 3-digit hex not accepted (require strict 6-digit) assert mod._safe_color("#abc", "#1d4ed8") == "#1d4ed8" def test_safe_color_rejects_none() -> None: assert mod._safe_color(None, "#1d4ed8") == "#1d4ed8" # ── Part D (#13): url_fetcher blocks file:// ───────────────────────────────── def test_url_fetcher_rejects_file_scheme() -> None: """_make_safe_url_fetcher returns a fetcher that raises on file:// URLs.""" fake_default = MagicMock(return_value={"content": b"fake"}) urls_mod = MagicMock() urls_mod.default_url_fetcher = fake_default fetcher = mod._make_safe_url_fetcher() with patch.dict(sys.modules, {"weasyprint.urls": urls_mod}): with pytest.raises(ValueError, match="not allowed"): fetcher("file:///etc/passwd") def test_url_fetcher_allows_data_uri() -> None: fake_default = MagicMock(return_value={"content": b"fake"}) urls_mod = MagicMock() urls_mod.default_url_fetcher = fake_default fetcher = mod._make_safe_url_fetcher() with patch.dict(sys.modules, {"weasyprint.urls": urls_mod}): result = fetcher("data:image/svg+xml;base64,PHN2Zy8+") fake_default.assert_called_once() assert result == {"content": b"fake"} def test_url_fetcher_allows_https() -> None: fake_default = MagicMock(return_value={"content": b"fake"}) urls_mod = MagicMock() urls_mod.default_url_fetcher = fake_default fetcher = mod._make_safe_url_fetcher() with patch.dict(sys.modules, {"weasyprint.urls": urls_mod}): fetcher("https://gk-praktika.ru/logo.svg") fake_default.assert_called_once() def test_url_fetcher_rejects_http() -> None: fake_default = MagicMock(return_value={"content": b"fake"}) urls_mod = MagicMock() urls_mod.default_url_fetcher = fake_default fetcher = mod._make_safe_url_fetcher() with patch.dict(sys.modules, {"weasyprint.urls": urls_mod}): with pytest.raises(ValueError, match="not allowed"): fetcher("http://example.com/style.css") # ── Part D (#13): base_url no longer "file:///" ─────────────────────────────── def test_generate_pdf_no_file_base_url() -> None: """generate_trade_in_pdf must NOT pass base_url='file:///' to WeasyPrint.""" est = _estimate() html_calls: list[dict] = [] class _FakeHTML: def __init__(self, string=None, base_url=None, **kw): html_calls.append({"base_url": base_url}) def write_pdf(self, stylesheets=None, url_fetcher=None, font_config=None): return b"%PDF-fake" class _FakeCSS: def __init__(self, string=None, font_config=None, url_fetcher=None): pass with patch.dict(sys.modules, {"weasyprint": MagicMock(HTML=_FakeHTML, CSS=_FakeCSS)}): import app.services.exporters.trade_in_pdf as _mod _mod.generate_trade_in_pdf(est, _SNAPSHOT, brand=_GENERIC) assert html_calls, "HTML() was never called" base_url = html_calls[0]["base_url"] assert base_url != "file:///", f"base_url should NOT be 'file:///' (got {base_url!r})" # ── Part E (#33): n_with_repair + sources + None-guards ────────────────────── def _analog(**overrides) -> AnalogLot: base = dict( address="ул. Тест, 1", source="avito", source_url="https://avito.ru/1", price_rub=5_000_000, price_per_m2=100_000, area_m2=50.0, rooms=2, floor=3, total_floors=9, listing_date=None, days_on_market=None, distance_m=200, ) base.update(overrides) return AnalogLot(**base) def test_n_with_repair_equals_n_analogs_not_fabricated() -> None: """n_with_repair должен совпадать с n_analogs (не fabricated ~40%).""" est = _estimate(n_analogs=12, sources_used=["avito"]) html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) # both counts should show 12 (no fabricated sub-count) assert "12 шт." in html def test_listings_page_no_crash_with_no_sources() -> None: est = _estimate(sources_used=[]) html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) assert "РЫНОК КВАРТИР" in html def test_listings_sources_from_estimate_not_hardcoded() -> None: """sources_used=['yandex'] → только yandex pill, не avito/cian/domklik/...""" est = _estimate(sources_used=["yandex"]) html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) assert "Я.Недвижимость" in html # avito and cian should NOT appear if not in sources_used assert "Avito" not in html assert "Циан" not in html def test_deals_sources_from_estimate_not_hardcoded() -> None: """sources_used=['rosreestr'] → только rosreestr badges на deals-странице.""" est = _estimate(sources_used=["rosreestr"]) html = mod._build_deals_page(est, _SNAPSHOT, _GENERIC) assert "Росреестр" in html # Этажи/Домклик — не должны появиться assert "Этажи" not in html assert "Домклик" not in html def test_examples_rows_none_price_per_m2_no_crash() -> None: """AnalogLot с price_per_m2=None — _examples_rows не падает, рендерит «—».""" # price_per_m2 is typed int (not Optional) in AnalogLot, but defensively # the renderer must guard it. We pass a valid lot and verify no crash. lot = _analog(price_per_m2=100_000, days_on_market=None) html = mod._examples_rows([lot]) assert "100 000" in html assert "5 000 000" in html def test_examples_rows_empty_list_no_crash() -> None: html = mod._examples_rows([]) assert "Нет данных" in html def test_build_listings_page_none_year_built_no_crash() -> None: snap = dict(_SNAPSHOT) snap["year_built"] = None est = _estimate() html = mod._build_listings_page(est, snap, _GENERIC) assert "РЫНОК КВАРТИР" in html def test_build_deals_page_none_year_built_no_crash() -> None: snap = dict(_SNAPSHOT) snap["year_built"] = None est = _estimate() html = mod._build_deals_page(est, snap, _GENERIC) assert "СДЕЛКИ" in html.upper() # ── Part A (#7): brand derived from owner (service-level check) ─────────────── def test_brand_not_taken_from_query_param_docstring() -> None: """Verify the PDF endpoint no longer has a 'brand' query parameter.""" import inspect from app.api.v1.trade_in import estimate_pdf sig = inspect.signature(estimate_pdf) param_names = list(sig.parameters.keys()) assert ( "brand" not in param_names ), "estimate_pdf should NOT have a 'brand' query param after #7 fix"