fix(tradein/privacy): не удалять B2B-строки в purge + находить телефон в другом формате при erasure (#2547)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m6s

Deep-review HIGH: purge_expired_trade_in_data удалял trade_in_estimates по
expires_at без разбора B2B/B2C -- эта колонка TTL ссылки/PDF, а не срок
хранения строки, и её единообразно проставляет каждой оценке estimator.py.
Прод-аудит: 1040/1057 строк просрочены, 911 из них у пилотов (admin,
kopylov, brusnika, praktika, pilottest, admintest, user1). DELETE теперь
ограничен created_by IS NULL -- ровно анонимная B2C-популяция (129 строк).
Докстринг миграции 231 переписан: явные цифры аудита, необратимость,
чек-лист (свежий SELECT count + один supervised прогон) перед enable.

Deep-review MEDIUM: erase_person_data сравнивал phone точным =, а lead.py
сохраняет номер как прислали (без нормализации, намеренно) -- разное
форматирование одного и того же номера не находилось, 0 строк удалялось,
но ответ всё равно был 200 "данные удалены". Сравнение переведено на
regexp_replace(x, '\D', '', 'g') с обеих сторон.

Оба фикса проверены живьём (throwaway Postgres 16 в docker, вне обычного
mock-only CI-лейна): без гварда пилотская строка удалялась вместе с
анонимной; без нормализации разноформатный телефон не находился. С
фиксами -- находит/не находит ровно как задумано. Добавлены self-skipping
live-DB тесты (паттерн test_house_dedup_merge.py::_live_session) плюс
статические SQL-guard тесты.
This commit is contained in:
bot-backend 2026-08-06 19:49:13 +03:00
parent 3ee99efaa4
commit 881730bf20
5 changed files with 275 additions and 4 deletions

View file

@ -16,6 +16,11 @@ WHO CAN BE IDENTIFIED, HONESTLY:
* `estimate_ids` -- if they still have the link/PDF from their estimate * `estimate_ids` -- if they still have the link/PDF from their estimate
(the UUID in the URL/QR-code IS their proof of "this is mine"). (the UUID in the URL/QR-code IS their proof of "this is mine").
* `phone` -- if they left a contact-request lead with that phone. * `phone` -- if they left a contact-request lead with that phone.
Matched by NORMALIZED DIGITS ONLY (regexp_replace strips everything
but 0-9 on both sides), not an exact string: lead.py stores
`payload.phone` exactly as typed (no E.164 normalization, by
design), so the caller's "+7 999 123-45-67" must still find a row
saved as "89991234567" or any other formatting of the same digits.
* `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own * `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own
Telegram chat id -- not guessable/spoofable by a third party the way Telegram chat id -- not guessable/spoofable by a third party the way
a name or IP would be). a name or IP would be).
@ -119,13 +124,32 @@ def erase_person_data(
# 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по # 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по
# телефону (лид мог быть оставлен без attach к оценке вовсе). # телефону (лид мог быть оставлен без attach к оценке вовсе).
#
# ⚠️ Телефон сравнивается по НОРМАЛИЗОВАННЫМ цифрам, не литералом
# (deep-review 2026-08-06, MEDIUM). app/api/v1/lead.py сохраняет
# payload.phone КАК ПРИСЛАЛИ (намеренно -- полная E.164-нормализация
# вне scope MVP, см. lead.py::_PHONE_PATTERN), т.е. одна и та же
# строка может лежать в БД как "+7 999 123-45-67" ИЛИ "89991234567"
# ИЛИ любой другой форматировкой той же маски. Точное `phone = :phone`
# находит строку только если запрашивающий пришлёт БУКВАЛЬНО ТОТ ЖЕ
# формат, каким когда-то ввёл номер -- почти никогда так. Раньше это
# молча удаляло 0 строк и всё равно возвращало 200 "данные удалены":
# для 152-ФЗ ложное подтверждение удаления хуже честной ошибки.
# `regexp_replace(x, '\\D', '', 'g')` с ОБЕИХ сторон сравнения снимает
# форматирование (пробелы/скобки/дефисы/+) и сравнивает голые цифры.
# Параметр -- CAST(:phone AS text), НЕ конкатенация (psycopg v3 / SQL
# injection convention, .claude/rules/backend.md).
ids_param = [str(i) for i in all_estimate_ids] ids_param = [str(i) for i in all_estimate_ids]
result = db.execute( result = db.execute(
text( text(
""" """
DELETE FROM trade_in_leads DELETE FROM trade_in_leads
WHERE estimate_id = ANY(CAST(:ids AS uuid[])) WHERE estimate_id = ANY(CAST(:ids AS uuid[]))
OR phone = :phone OR (
CAST(:phone AS text) IS NOT NULL
AND regexp_replace(phone, '\\D', '', 'g')
= regexp_replace(CAST(:phone AS text), '\\D', '', 'g')
)
""" """
), ),
{"ids": ids_param, "phone": phone}, {"ids": ids_param, "phone": phone},

View file

@ -23,6 +23,27 @@ WHAT:
it has its OWN retention clock (trade_in_leads.expires_at) and its own PII (phone), it has its OWN retention clock (trade_in_leads.expires_at) and its own PII (phone),
purged independently below. purged independently below.
trade_in_estimates DELETE is scoped to `created_by IS NULL` (deep-review finding,
2026-08-06, HIGH): `expires_at` on this table is set UNCONDITIONALLY for every
estimate, B2B pilot or anonymous (`now + settings.trade_in_estimate_retention_hours`,
see app/services/estimator.py) -- it is a TTL on the ESTIMATE LINK/PDF staying
resolvable (GET /estimate/{id}: 404 past expiry, PDF export: 410 past expiry), NOT a
declared retention deadline for the ROW. B2B pilots' consent is closed by contract
(see migration 229's `consent` column asymmetry: NULL for `created_by IS NOT NULL`,
the exact same B2B-vs-B2C split used here) and their estimates are the live basis for
/trade-in/history, /team/employees/{id}/history and the cache-stats dashboards (see
app/api/v1/trade_in.py, app/api/v1/team.py) -- deleting them past a 24h *link* TTL
would be silent, irreversible data loss of pilots' own operational data, not a
privacy-driven cleanup. Audited against prod on 2026-08-06: of 1057 rows, 1040 had
already crossed `expires_at`, and 911 of THOSE belonged to named pilots
(`created_by` set: admin, kopylov, brusnika, praktika, pilottest, admintest, user1).
Without the `created_by IS NULL` guard, one unattended run at
`batch_size=500, max_batches=20` (the defaults) would have deleted essentially the
whole table. `created_by IS NULL` is the honest B2C population -- 129 rows in that
same audit. trade_in_leads has no `created_by` column (never had a B2B/B2C split --
its own `expires_at` really is a 180-day retention deadline, not a link TTL, see
migration 231) so its DELETE below is intentionally NOT scoped the same way.
BATCHING (не единый DELETE по всей таблице): BATCHING (не единый DELETE по всей таблице):
Each table is drained in batches of `batch_size` rows (default Each table is drained in batches of `batch_size` rows (default
settings.trade_in_purge_batch_size), each batch its OWN statement + its OWN commit settings.trade_in_purge_batch_size), each batch its OWN statement + its OWN commit
@ -59,6 +80,7 @@ _DELETE_EXPIRED_ESTIMATES_SQL = text(
WHERE id IN ( WHERE id IN (
SELECT id FROM trade_in_estimates SELECT id FROM trade_in_estimates
WHERE expires_at < NOW() WHERE expires_at < NOW()
AND created_by IS NULL
ORDER BY expires_at ORDER BY expires_at
LIMIT CAST(:batch_size AS int) LIMIT CAST(:batch_size AS int)
) )

View file

@ -34,9 +34,35 @@
-- 175_scrape_schedules_seed_domclick_detail_backfill.sql): это ПЕРВАЯ -- 175_scrape_schedules_seed_domclick_detail_backfill.sql): это ПЕРВАЯ
-- автоматическая задача физического DELETE персональных данных в trade-in — -- автоматическая задача физического DELETE персональных данных в trade-in —
-- заслуживает supervised первого прогона (смотри логи/counters вручную) -- заслуживает supervised первого прогона (смотри логи/counters вручную)
-- перед тем, как доверить её расписанию. Включение — отдельный ручной шаг -- перед тем, как доверить её расписанию.
-- (UPDATE scrape_schedules SET enabled=true WHERE source= --
-- 'purge_expired_trade_in_data'). -- ⚠️ ПЕРЕД ВКЛЮЧЕНИЕМ (deep-review 2026-08-06, HIGH — читай целиком, не
-- только команду в конце): задача удаляет строки trade_in_estimates
-- физически и НЕОБРАТИМО. DELETE ограничен `created_by IS NULL` — только
-- анонимные B2C-оценки (см. докстринг app/tasks/purge_expired_trade_in_
-- data.py, раздел про асимметрию expires_at = TTL ссылки/PDF, а не срок
-- хранения строки) — строки B2B-пилотов (`created_by` задан) задача НЕ
-- трогает НИКОГДА, что бы ни стояло в expires_at. Аудит на проде на момент
-- этой правки: из 1057 строк trade_in_estimates 1040 уже просрочены
-- (expires_at < NOW()), но ТОЛЬКО 129 из них имеют created_by IS NULL
-- (реальная B2C-популяция под удаление) — остальные 911 принадлежат
-- пилотам (admin/kopylov/brusnika/praktika/pilottest/admintest/user1) и
-- гвардом защищены от удаления. Эти цифры УСТАРЕЮТ — не включай задачу по
-- их памяти. Перед `UPDATE scrape_schedules SET enabled=true WHERE
-- source='purge_expired_trade_in_data'`:
-- 1. Свежий `SELECT count(*) FROM trade_in_estimates WHERE expires_at
-- < NOW() AND created_by IS NULL` — если число на порядок больше
-- 129 (или created_by-гвард почему-то отсутствует в текущем коде
-- задачи) — СТОП, разберись, прежде чем включать.
-- 2. Прогони задачу вручную ОДИН раз (app/tasks/
-- purge_expired_trade_in_data.py, синхронная функция) и сверь
-- counters с п.1 (задача логирует batch/running_total через
-- logger.info в _drain_expired) — supervised первый прогон, не
-- включение вслепую.
-- 3. Только после совпадения счётчиков — enable в scrape_schedules;
-- расписание подхватит на следующем тике планировщика (крутится в
-- контейнере tradein-scraper, не tradein-backend).
-- Включение — отдельный ручной шаг, эта миграция его НЕ делает.
-- --
-- IDEMPOTENCY / SAFETY: -- IDEMPOTENCY / SAFETY:
-- - ADD COLUMN IF NOT EXISTS + UPDATE ... WHERE expires_at IS NULL (no-op на -- - ADD COLUMN IF NOT EXISTS + UPDATE ... WHERE expires_at IS NULL (no-op на

View file

@ -15,6 +15,7 @@ Covers app/services/data_erasure.py:
from __future__ import annotations from __future__ import annotations
import os import os
import re
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import MagicMock from unittest.mock import MagicMock
@ -137,6 +138,26 @@ def test_erase_by_phone_only_touches_only_leads() -> None:
assert params["ids"] == [] assert params["ids"] == []
def test_phone_delete_normalizes_digits_on_both_sides() -> None:
"""Regression guard for the deep-review MEDIUM finding (2026-08-06):
lead.py stores phone exactly as typed (no E.164 normalization, by
design), so a differently-formatted-but-same-number erasure request
('+7 999 123-45-67' vs a stored '89991234567') must still match. The old
exact `phone = :phone` comparison silently deleted 0 rows and still
returned HTTP 200 'erased' -- worse than an honest error under 152-ФЗ.
Both sides of the comparison must go through regexp_replace, and the
literal-equality path must be gone."""
db = MagicMock()
db.execute.side_effect = [_Result(rowcount=1)]
data_erasure.erase_person_data(db, phone="+7 999 123-45-67")
sql = _sql_of(db.execute.call_args_list[0])
assert sql.count("regexp_replace") == 2
assert "phone = :phone" not in sql
assert not re.search(r":\w+::", sql) # psycopg v3 CAST trap
def test_erase_by_tg_chat_id_only_touches_only_tg_support() -> None: 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 """Anonymous person with NO username, NO estimate link, NO lead phone -- but
they DID message @MERAsupport_bot -- can still be identified by their own they DID message @MERAsupport_bot -- can still be identified by their own
@ -158,3 +179,72 @@ def test_erase_by_tg_chat_id_only_touches_only_tg_support() -> None:
calls = db.execute.call_args_list calls = db.execute.call_args_list
assert "DELETE FROM tg_support_users" in _sql_of(calls[-1]) assert "DELETE FROM tg_support_users" in _sql_of(calls[-1])
assert calls[-1].args[1]["chat_id"] == 123456789 assert calls[-1].args[1]["chat_id"] == 123456789
# ── Optional real-Postgres behavioural test (self-skips without a DB) ──────────
# Same pattern as tests/test_house_dedup_merge.py::_live_session -- CI runs the
# mock-only lane (DATABASE_URL is a placeholder), so this self-skips there; it
# only executes with a real reachable Postgres (e.g. local dev DB).
def _live_session() -> Any | None:
"""Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None."""
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
from sqlalchemy import text as _t
conn.execute(_t("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_erase_by_phone_finds_differently_formatted_number() -> None:
"""End-to-end on a real DB: a lead stored with phone EXACTLY as typed
('89991234567', no separators) must still be found and deleted when the
erasure requester supplies the SAME digits in a DIFFERENT format
('8 (999) 123-45-67') -- proves the regexp_replace normalization fix
actually matches, not just that the SQL text contains the function name."""
from sqlalchemy import text as _t
db = _live_session()
assert db is not None
lead_id: Any = None
try:
row = db.execute(
_t(
"INSERT INTO trade_in_leads (phone, consent, expires_at) "
"VALUES (:phone, TRUE, NOW() + interval '180 days') "
"RETURNING id"
),
{"phone": "89991234567"},
).fetchone()
assert row is not None
lead_id = row[0]
db.commit()
out = data_erasure.erase_person_data(db, phone="8 (999) 123-45-67")
assert out["trade_in_leads_deleted"] == 1
remaining = db.execute(
_t("SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid)"),
{"id": str(lead_id)},
).scalar()
assert remaining == 0
finally:
if lead_id is not None:
db.execute(
_t("DELETE FROM trade_in_leads WHERE id = CAST(:id AS uuid)"),
{"id": str(lead_id)},
)
db.commit()
db.close()

View file

@ -18,6 +18,7 @@ import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4
import pytest import pytest
@ -149,6 +150,35 @@ def test_leads_sql_is_delete_not_update() -> None:
assert not re.search(r":\w+::", sql) assert not re.search(r":\w+::", sql)
def test_estimates_sql_scopes_delete_to_anonymous_created_by_null() -> None:
"""Regression guard for the deep-review HIGH finding (2026-08-06):
trade_in_estimates.expires_at is set UNCONDITIONALLY on every estimate
(B2B pilot or anonymous) as a TTL on the estimate LINK/PDF staying
resolvable, NOT a declared row-retention deadline -- see the task's
module docstring. Without this guard, prod audit showed 1040/1057 rows
past expires_at, 911 of them belonging to named pilots (admin, kopylov,
brusnika, praktika, pilottest, admintest, user1); one unattended run at
the seeded defaults (batch_size=500, max_batches=20) would have deleted
essentially the whole table, including pilots' own operational history
(/trade-in/history, /team/employees/{id}/history, cache-stats all read
trade_in_estimates without an expires_at filter). The DELETE must stay
scoped to created_by IS NULL -- the honest B2C population (129 rows in
that same audit)."""
sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text
assert "created_by IS NULL" in sql
assert not re.search(r":\w+::", sql)
def test_leads_sql_has_no_created_by_guard() -> None:
"""trade_in_leads has NO created_by column at all (never had a B2B/B2C
split) -- its expires_at IS a genuine 180-day retention deadline (see
migration 231), not a link/PDF-access TTL like trade_in_estimates. This
documents the asymmetry explicitly so a future 'fix' doesn't bolt a
created_by filter onto a table that doesn't have the column."""
sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text
assert "created_by" not in sql
def test_sql_does_not_delete_whole_table_unbounded() -> None: def test_sql_does_not_delete_whole_table_unbounded() -> None:
"""Neither statement is a bare `DELETE FROM table` -- both scope via a """Neither statement is a bare `DELETE FROM table` -- both scope via a
subselect + LIMIT batch.""" subselect + LIMIT batch."""
@ -230,3 +260,82 @@ def test_migration_231_seeds_purge_schedule_disabled_by_default() -> None:
def test_migration_231_no_psycopg_trap() -> None: def test_migration_231_no_psycopg_trap() -> None:
sql = _MIGRATION_231.read_text("utf-8") sql = _MIGRATION_231.read_text("utf-8")
assert not re.search(r":\w+::", sql) assert not re.search(r":\w+::", sql)
# ── Optional real-Postgres behavioural test (self-skips without a DB) ──────────
# Same pattern as tests/test_house_dedup_merge.py::_live_session -- CI runs the
# mock-only lane (DATABASE_URL is a placeholder), so this self-skips there; it
# only executes with a real reachable Postgres (e.g. local dev DB).
def _live_session() -> Any | None:
"""Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None."""
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
from sqlalchemy import text as _t
conn.execute(_t("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_purge_deletes_only_anonymous_expired_estimates() -> None:
"""End-to-end on a real DB: one expired B2B row (created_by set) and one
expired anonymous row (created_by IS NULL) both exist -- a real run of
purge_expired_trade_in_data must delete ONLY the anonymous one. This is
the exact scenario the deep-review HIGH finding (2026-08-06) flagged:
without the created_by IS NULL guard, the pilot row would also be gone."""
from sqlalchemy import text as _t
db = _live_session()
assert db is not None
anon_id = uuid4()
pilot_id = uuid4()
try:
db.execute(
_t(
"INSERT INTO trade_in_estimates "
"(id, address, area_m2, rooms, floor, total_floors, "
" median_price, range_low, range_high, median_price_per_m2, confidence, "
" expires_at, created_by) VALUES "
"(CAST(:anon_id AS uuid), 'purge-test аноним', 40, 1, 2, 5, "
" 5000000, 4500000, 5500000, 125000, 'low', "
" NOW() - interval '1 hour', NULL), "
"(CAST(:pilot_id AS uuid), 'purge-test пилот', 40, 1, 2, 5, "
" 5000000, 4500000, 5500000, 125000, 'low', "
" NOW() - interval '1 hour', 'pytest_purge_guard')"
),
{"anon_id": str(anon_id), "pilot_id": str(pilot_id)},
)
db.commit()
task_mod.purge_expired_trade_in_data(db, run_id=999999999, batch_size=100, max_batches=5)
remaining_ids = {
str(r)
for r in db.execute(
_t("SELECT id FROM trade_in_estimates " "WHERE id = ANY(CAST(:ids AS uuid[]))"),
{"ids": [str(anon_id), str(pilot_id)]},
)
.scalars()
.all()
}
assert str(anon_id) not in remaining_ids, "anonymous expired row must be purged"
assert str(pilot_id) in remaining_ids, "B2B pilot row must survive despite expires_at"
finally:
db.execute(
_t("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"),
{"ids": [str(anon_id), str(pilot_id)]},
)
db.commit()
db.close()