gendesign/tradein-mvp/backend/tests/test_data_erasure.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

160 lines
5.7 KiB
Python

"""ЭТАП 4 B2C launch — right-to-erasure mechanism (part C).
Covers app/services/data_erasure.py:
- at least one identifier required (ValueError, no db.execute at all)
- username (B2B pilot): estimates + their leads + web_support_threads deleted
- estimate_ids only (anonymous, has the link/PDF): estimates + linked leads deleted,
web_support/tg_support untouched (no username = nothing to key them by)
- phone only: only leads deleted (no estimate/support action)
- tg_chat_id only: only tg_support deleted (anonymous Telegram-support path)
- ORDER: leads are captured/deleted BEFORE estimates (estimate_id FK is
ON DELETE SET NULL -- deleting estimates first would orphan the join)
- commits once at the end
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import data_erasure
class _Result:
def __init__(self, rowcount: int = 0, scalar_ids: list[Any] | None = None) -> None:
self.rowcount = rowcount
self._scalar_ids = scalar_ids or []
def scalars(self) -> SimpleNamespace:
return SimpleNamespace(all=lambda: self._scalar_ids)
def _sql_of(call: Any) -> str:
stmt = call.args[0]
return str(getattr(stmt, "text", stmt))
def test_requires_at_least_one_identifier() -> None:
db = MagicMock()
with pytest.raises(ValueError, match="at least one identifier"):
data_erasure.erase_person_data(db)
assert not db.execute.called
assert not db.commit.called
def test_erase_by_username_deletes_estimates_leads_and_web_support() -> None:
db = MagicMock()
owned_id = uuid4()
db.execute.side_effect = [
_Result(scalar_ids=[owned_id]), # SELECT id FROM trade_in_estimates WHERE created_by
_Result(rowcount=2), # DELETE FROM trade_in_leads
_Result(rowcount=1), # DELETE FROM trade_in_estimates
_Result(rowcount=3), # DELETE FROM web_support_threads
]
out = data_erasure.erase_person_data(db, username="kopylov")
assert out == {
"trade_in_estimates_deleted": 1,
"trade_in_leads_deleted": 2,
"web_support_deleted": 3,
"tg_support_deleted": 0,
}
assert db.commit.called
calls = db.execute.call_args_list
assert "SELECT id FROM trade_in_estimates" in _sql_of(calls[0])
assert "created_by" in _sql_of(calls[0])
assert "DELETE FROM trade_in_leads" in _sql_of(calls[1])
assert "DELETE FROM trade_in_estimates" in _sql_of(calls[2])
assert "DELETE FROM web_support_threads" in _sql_of(calls[3])
# estimate_ids captured from the SELECT reach the estimates DELETE.
estimates_delete_params = calls[2].args[1]
assert str(owned_id) in estimates_delete_params["ids"]
def test_leads_deleted_before_estimates_order() -> None:
"""FK trade_in_leads.estimate_id is ON DELETE SET NULL -- capturing/deleting
leads must happen BEFORE the estimates DELETE, else the join key is gone."""
db = MagicMock()
owned_id = uuid4()
db.execute.side_effect = [
_Result(scalar_ids=[owned_id]),
_Result(rowcount=0),
_Result(rowcount=1),
_Result(rowcount=0),
]
data_erasure.erase_person_data(db, username="kopylov")
calls = db.execute.call_args_list
leads_idx = next(i for i, c in enumerate(calls) if "DELETE FROM trade_in_leads" in _sql_of(c))
estimates_idx = next(
i for i, c in enumerate(calls) if "DELETE FROM trade_in_estimates" in _sql_of(c)
)
assert leads_idx < estimates_idx
def test_erase_by_estimate_ids_only_no_web_or_tg_support_touched() -> None:
db = MagicMock()
eid = uuid4()
db.execute.side_effect = [
_Result(rowcount=1), # DELETE FROM trade_in_leads (matches estimate_id)
_Result(rowcount=1), # DELETE FROM trade_in_estimates
]
out = data_erasure.erase_person_data(db, estimate_ids=[eid])
assert out == {
"trade_in_estimates_deleted": 1,
"trade_in_leads_deleted": 1,
"web_support_deleted": 0,
"tg_support_deleted": 0,
}
assert db.execute.call_count == 2 # no username -> no SELECT, no web_support DELETE
def test_erase_by_phone_only_touches_only_leads() -> None:
db = MagicMock()
db.execute.side_effect = [_Result(rowcount=1)] # DELETE FROM trade_in_leads WHERE phone=...
out = data_erasure.erase_person_data(db, phone="+79123456789")
assert out == {
"trade_in_estimates_deleted": 0,
"trade_in_leads_deleted": 1,
"web_support_deleted": 0,
"tg_support_deleted": 0,
}
assert db.execute.call_count == 1
params = db.execute.call_args_list[0].args[1]
assert params["phone"] == "+79123456789"
assert params["ids"] == []
def test_erase_by_tg_chat_id_only_touches_only_tg_support() -> None:
"""Anonymous person with NO username, NO estimate link, NO lead phone -- but
they DID message @MERAsupport_bot -- can still be identified by their own
Telegram chat_id (see module docstring: not spoofable by a third party)."""
db = MagicMock()
db.execute.side_effect = [
_Result(rowcount=0), # DELETE FROM trade_in_leads (no ids, no phone -> matches nothing)
_Result(rowcount=5), # DELETE FROM tg_support_users
]
out = data_erasure.erase_person_data(db, tg_chat_id=123456789)
assert out == {
"trade_in_estimates_deleted": 0,
"trade_in_leads_deleted": 0,
"web_support_deleted": 0,
"tg_support_deleted": 5,
}
calls = db.execute.call_args_list
assert "DELETE FROM tg_support_users" in _sql_of(calls[-1])
assert calls[-1].args[1]["chat_id"] == 123456789