"""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"