gendesign/tradein-mvp/backend/tests/test_pdf_security.py
bot-backend 38d44f46b7
Some checks failed
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / test (push) Successful in 25s
Deploy Trade-In / build-backend (push) Successful in 39s
fix(pdf): brand-spoof + median=0 empty-state + SSRF + дисклеймер + sources (Refs #772) (#789)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-05-30 16:55:56 +00:00

437 lines
15 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
assert "EKБ-" in html # report number prefix
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):
return b"%PDF-fake"
class _FakeCSS:
def __init__(self, string=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):
return b"%PDF-fake"
class _FakeCSS:
def __init__(self, string=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:
est = _estimate()
html = mod._build_cover(est, _SNAPSHOT, _PRAKTIKA)
assert "<img" in html
assert "gk-praktika.ru" in html
def test_cover_falls_back_to_name_when_logo_none() -> None:
est = _estimate()
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
assert "<img" not in html
assert "TRADE-IN" in html.upper()
# ── 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):
return b"%PDF-fake"
class _FakeCSS:
def __init__(self, string=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"