gendesign/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py
bot-backend 5626d9e720
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m11s
feat(mera/b2c): правовая рамка — согласие до сохранения, удаление по сроку и по запросу (этап 4 из 8)
Три дефекта, каждый блокировал легальный публичный запуск.

1. Адрес физлица сохранялся в базу ДО любого согласия: согласие фиксировалось
   только на форме заявки, то есть ПОСЛЕ записи адреса. Для пилота с договором
   терпимо, для человека с улицы — нет. Проверка согласия поставлена первой
   строкой расчёта, до геокодирования и до обоих мест записи адреса.

   Хранение — колонками на самой оценке, 1:1 с уже работающим прецедентом для
   заявок (миграция 182): IP клиента, версия политики, дословный снимок текста.
   Отдельная таблица событий не заводилась: согласие даётся ровно на создание
   этой строки, и когда строка удаляется по сроку, исчезновение доказательства
   вместе с данными логично.

   Enforcement НЕ выводится из пустого created_by — первая версия так и делала
   и сломала 92 несвязанных теста оценщика, которые зовут расчёт без имени
   пользователя, проверяя ценовую логику. Вместо этого явный флаг, который
   выставляет единственный боевой вызывающий. B2B-поток не тронут: поле
   согласия опционально, иначе сломались бы пилоты, чей фронт его не шлёт.

2. Срок жизни оценки применялся только как фильтр при чтении — физического
   удаления не было ни в одной фоновой задаче, данные жили вечно вопреки
   декларированному сроку. Заведена задача удаления пачками с ограничением на
   прогон и коммитом после каждой пачки, идемпотентная. В расписании она
   ВЫКЛЮЧЕНА: это первая автоматическая задача, удаляющая персональные данные,
   и первый прогон должен быть под наблюдением.

3. Пути «удалите мои данные» не было. Добавлен сервис удаления и админская
   ручка. Ключи: имя пользователя, идентификатор оценки, телефон, чат в
   телеграме.

   Честно зафиксировано в коде: аноним без ссылки на оценку, без оставленного
   телефона и без обращения в поддержку неидентифицируем — удалить его данные
   без дополнительной идентификации нельзя. Отдельно: удаление чистит только
   копию в базе, зеркало переписки в телеграм-топике не удаляется ничем в
   кодовой базе, нужен ручной шаг.

4. Соответствие текста согласия на фронте и снимка на бэке держалось на
   комментарии. Теперь есть тест, который ловит расхождение.

Сроки хранения вынесены в настройки. Значение для заявок предложено инженерно
(типичный отраслевой диапазон), юридически обоснованный срок — за юристом, и
это записано в коде.

Тесты: 2775 passed.
2026-07-28 15:24:21 +03:00

89 lines
3.7 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.

"""ЭТАП 4 B2C launch — consent-text sync guard (part D).
WHY:
_CONSENT_TEXT_SNAPSHOT in app/api/v1/lead.py is a durable 152-ФЗ
proof-of-consent: it must be the EXACT text a user actually saw and agreed
to. Before this test, the only thing keeping it in sync with the real
frontend checkbox label (LeadForm.tsx) was a code COMMENT ("Должен
ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx"). A comment cannot fail CI
-- a frontend copy edit could silently drift from the backend snapshot,
and every future lead's "proof" would then misrepresent what the user
actually saw.
WHAT:
Extract the actual consent-checkbox label text straight out of
LeadForm.tsx (regex, no JSX parser needed -- there is exactly one <span>
in the file today) and assert it matches _CONSENT_TEXT_SNAPSHOT byte-for-
byte after whitespace normalisation (JSX text nodes wrap across source
lines; the DOM-rendered text collapses that to single spaces). If someone
edits ONE side without the other, this test fails.
NOTE: the NEW anonymous-estimate consent text (_ESTIMATE_CONSENT_TEXT_SNAPSHOT
in app/services/estimator.py, ЭТАП 4 part A) has NO frontend counterpart yet
-- the anonymous /estimate flow isn't live (rbac_guard still requires
X-Authenticated-User on every non-public path, see app/core/rbac.py). When
that flow ships its own consent checkbox, add a second sync test here
mirroring this one -- do NOT rely on a comment for that pairing either.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_FRONTEND_LEAD_FORM = (
Path(__file__).resolve().parents[2]
/ "frontend"
/ "src"
/ "components"
/ "trade-in"
/ "v2"
/ "LeadForm.tsx"
)
def _extract_span_text(tsx_source: str) -> str:
"""Pull the text content of the (single) <span>...</span> in LeadForm.tsx,
whitespace-normalised the same way a browser collapses JSX text-node
whitespace when rendering (multiple lines/indentation -> single spaces).
"""
match = re.search(r"<span>\s*(.*?)\s*</span>", tsx_source, re.DOTALL)
assert match is not None, "no <span> found in LeadForm.tsx -- consent label markup changed"
return re.sub(r"\s+", " ", match.group(1)).strip()
def test_frontend_lead_form_exists() -> None:
assert _FRONTEND_LEAD_FORM.is_file(), f"missing frontend file: {_FRONTEND_LEAD_FORM}"
def test_backend_consent_snapshot_matches_frontend_checkbox_label() -> None:
"""The whole point: this FAILS if lead.py._CONSENT_TEXT_SNAPSHOT and
LeadForm.tsx's checkbox label ever diverge -- no longer just a comment."""
from app.api.v1.lead import _CONSENT_TEXT_SNAPSHOT
frontend_text = _extract_span_text(_FRONTEND_LEAD_FORM.read_text(encoding="utf-8"))
backend_text = re.sub(r"\s+", " ", _CONSENT_TEXT_SNAPSHOT).strip()
assert frontend_text == backend_text, (
"consent text drift detected between app/api/v1/lead.py._CONSENT_TEXT_SNAPSHOT "
"and frontend/src/components/trade-in/v2/LeadForm.tsx checkbox label -- the "
"152-ФЗ proof-of-consent snapshot no longer matches what users actually see. "
"Bump _CONSENT_POLICY_VERSION and update _CONSENT_TEXT_SNAPSHOT together with "
"any frontend copy change.\n"
f" frontend: {frontend_text!r}\n"
f" backend: {backend_text!r}"
)
def test_extract_span_text_helper_is_whitespace_insensitive() -> None:
"""Sanity check on the extraction helper itself, independent of the real file."""
sample = """
<span>
Line one
Line two
</span>
"""
assert _extract_span_text(sample) == "Line one Line two"