gendesign/tradein-mvp/backend/tests/test_pdf_security.py
bot-backend 42a50cf814
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 59s
CI / frontend-tests (pull_request) Has been skipped
fix(tradein/pdf): running @page header/footer → ровно 4 страницы (без пустых)
Header/footer сидели В ПОТОКЕ каждой секции (~110pt/стр.) → cover переполнялся
(advice-таблица уезжала на почти-пустую стр. 2), footer listings-секции сиротел
на полностью пустую стр. 4 → отчёт 6 страниц вместо 4.

Fix: header+footer вынесены в running @page margin-boxes (position:running() +
@top-center/@bottom-center) — рендерятся в полях каждой страницы, не занимая
поток. Chrome строится ОДИН раз в generate_trade_in_pdf (else-ветка, не
insufficient_data) и оборачивается в .running-header/.running-footer; из 4
билдеров inline-вызовы header/footer убраны + очищены осиротевшие переменные.
Top-margin 20→25mm под running-header. Дисклеймер offer-страницы перенесён из
footer page_note в inline <p> (сохранён). Убрана дубль-строка advice на cover
(«до 15% при самопродаже» — детально расписана на стр. 04).

Верифицировано рендером реального WeasyPrint 69 в прод-образе: ровно 4 чистые
страницы, бренд МЕРА в шапке/футере всех страниц, «Год постройки —», дисклеймер
на месте. ruff clean; test_pdf_security 32/32; test_trade_in_pdf_dual_price 25/25.
2026-07-13 21:01:53 +03:00

447 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 "<img" in header_html
assert "gk-praktika.ru" in header_html
def test_cover_falls_back_to_name_when_logo_none() -> None:
# Logo-specific check via _page_header directly — не проверяем отсутствие <img> во
# всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как
# matplotlib SVG <img> (см. _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 "<img" not in header_html
# ── Part D (#13): _safe_logo_url ─────────────────────────────────────────────
def test_safe_logo_url_accepts_allowlisted_https() -> 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"