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
Три дефекта, каждый блокировал легальный публичный запуск. 1. Адрес физлица сохранялся в базу ДО любого согласия: согласие фиксировалось только на форме заявки, то есть ПОСЛЕ записи адреса. Для пилота с договором терпимо, для человека с улицы — нет. Проверка согласия поставлена первой строкой расчёта, до геокодирования и до обоих мест записи адреса. Хранение — колонками на самой оценке, 1:1 с уже работающим прецедентом для заявок (миграция 182): IP клиента, версия политики, дословный снимок текста. Отдельная таблица событий не заводилась: согласие даётся ровно на создание этой строки, и когда строка удаляется по сроку, исчезновение доказательства вместе с данными логично. Enforcement НЕ выводится из пустого created_by — первая версия так и делала и сломала 92 несвязанных теста оценщика, которые зовут расчёт без имени пользователя, проверяя ценовую логику. Вместо этого явный флаг, который выставляет единственный боевой вызывающий. B2B-поток не тронут: поле согласия опционально, иначе сломались бы пилоты, чей фронт его не шлёт. 2. Срок жизни оценки применялся только как фильтр при чтении — физического удаления не было ни в одной фоновой задаче, данные жили вечно вопреки декларированному сроку. Заведена задача удаления пачками с ограничением на прогон и коммитом после каждой пачки, идемпотентная. В расписании она ВЫКЛЮЧЕНА: это первая автоматическая задача, удаляющая персональные данные, и первый прогон должен быть под наблюдением. 3. Пути «удалите мои данные» не было. Добавлен сервис удаления и админская ручка. Ключи: имя пользователя, идентификатор оценки, телефон, чат в телеграме. Честно зафиксировано в коде: аноним без ссылки на оценку, без оставленного телефона и без обращения в поддержку неидентифицируем — удалить его данные без дополнительной идентификации нельзя. Отдельно: удаление чистит только копию в базе, зеркало переписки в телеграм-топике не удаляется ничем в кодовой базе, нужен ручной шаг. 4. Соответствие текста согласия на фронте и снимка на бэке держалось на комментарии. Теперь есть тест, который ловит расхождение. Сроки хранения вынесены в настройки. Значение для заявок предложено инженерно (типичный отраслевой диапазон), юридически обоснованный срок — за юристом, и это записано в коде. Тесты: 2775 passed.
89 lines
3.7 KiB
Python
89 lines
3.7 KiB
Python
"""ЭТАП 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"
|