fix(tradein/privacy): нормализация телефона к каноническому РФ-виду при 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 3m9s
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 3m9s
Follow-up к прошлому фиксу (regexp_replace \D): чистое удаление форматирования не закрывало разрыв, который сам ревьюер привёл в примере -- "+7 999 123-45-67" и "89991234567" после digit-stripping дают РАЗНЫЕ строки (79991234567 vs 89991234567, différent на первой цифре) -- классическая для РФ путаница 8/+7 trunk-префикса. _ru_phone_norm_sql(expr) добавляет второй шаг: если после digit-stripping получилось РОВНО 11 цифр с ведущей '8' -- заменить её на '7'. Точное тождество для российской нумерации, не эвристика (обсуждали: усечение до "последних 10 цифр" риск-скориальнее -- склеивает номера разных стран, удаление чужих данных хуже неудаления своих). Оба вызова (_PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL) строят SQL-структуру из статичных фрагментов (имя колонки / CAST(:phone AS text)) -- ни один телефон не попадает в текст запроса напрямую. Живая проверка (throwaway Postgres 16 в docker): лид "89991234567" находится и удаляется по запросу "+7 999 123-45-67" -- ровно кейс из ревью. Встроенный counterfactual в самом тесте доказывает, что чистый digit-strip (прошлая версия фикса) для этой пары находит 0 строк. Negative control: номер, отличающийся одной значащей цифрой, НЕ удаляется (защита от ложного совпадения = удаления чужих данных).
This commit is contained in:
parent
881730bf20
commit
4ee4d4b8e2
2 changed files with 195 additions and 26 deletions
|
|
@ -16,11 +16,13 @@ WHO CAN BE IDENTIFIED, HONESTLY:
|
|||
* `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").
|
||||
* `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
|
||||
Matched by CANONICAL RU DIGITS on both sides (see
|
||||
`_ru_phone_norm_sql` below), 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.
|
||||
design), so "+7 999 123-45-67", "8 (999) 123-45-67" and
|
||||
"89991234567" must all find the same row. Covers ONLY the
|
||||
RU 8-vs-7 trunk-prefix case (exact digit-count identity, no
|
||||
heuristic truncation) -- see the helper's docstring for why.
|
||||
* `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own
|
||||
Telegram chat id -- not guessable/spoofable by a third party the way
|
||||
a name or IP would be).
|
||||
|
|
@ -68,6 +70,40 @@ from sqlalchemy.orm import Session
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ru_phone_norm_sql(expr: str) -> str:
|
||||
"""SQL-фрагмент: нормализация телефона к каноническому РФ-виду (11 цифр,
|
||||
ведущая '7'), для сравнения "разного форматирования одного и того же номера"
|
||||
(deep-review 2026-08-06, MEDIUM + follow-up).
|
||||
|
||||
Два шага: 1) убрать всё, кроме цифр; 2) если получилось РОВНО 11 цифр с
|
||||
ведущей '8' -- заменить её на '7'. Это ТОЧНОЕ тождество для российской
|
||||
нумерации (8 и +7 -- один и тот же trunk-префикс), не эвристика: длина
|
||||
проверяется явно (=11), заменяется РОВНО одна ведущая цифра. Специально
|
||||
НЕ "последние 10 цифр" -- усечение убрало бы риск ложных совпадений
|
||||
неточно: оно склеивает номера РАЗНЫХ стран с теми же 10 хвостовыми
|
||||
цифрами, а удаление ЧУЖИХ данных по erasure-запросу хуже, чем
|
||||
неудаление своих. Номера другой длины/страны просто не совпадут ни на
|
||||
этом шаге, ни дальше -- безопасный отказ, не false positive.
|
||||
|
||||
`expr` -- ВСЕГДА статичный SQL-фрагмент (имя колонки или
|
||||
`CAST(:bind AS type)`), НИКОГДА значение параметра: эта функция строит
|
||||
структуру запроса из литералов, вызывающих её мест ровно два (см.
|
||||
_PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL ниже) -- ни один телефон
|
||||
не попадает в текст SQL напрямую, только через bind-параметр `:phone`.
|
||||
"""
|
||||
stripped = f"regexp_replace({expr}, '\\D', '', 'g')"
|
||||
return (
|
||||
f"(CASE WHEN length({stripped}) = 11 AND left({stripped}, 1) = '8' "
|
||||
f"THEN '7' || substring({stripped} FROM 2) ELSE {stripped} END)"
|
||||
)
|
||||
|
||||
|
||||
# Предвычисленные один раз -- обе стороны сравнения телефона в erase_person_data
|
||||
# (колонка trade_in_leads.phone / входной CAST(:phone AS text)).
|
||||
_PHONE_COLUMN_NORM_SQL = _ru_phone_norm_sql("phone")
|
||||
_PHONE_PARAM_NORM_SQL = _ru_phone_norm_sql("CAST(:phone AS text)")
|
||||
|
||||
|
||||
def erase_person_data(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -125,30 +161,35 @@ def erase_person_data(
|
|||
# 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по
|
||||
# телефону (лид мог быть оставлен без 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`
|
||||
# ⚠️ Телефон сравнивается по КАНОНИЧЕСКОМУ РФ-виду, не литералом
|
||||
# (deep-review 2026-08-06, MEDIUM + follow-up). app/api/v1/lead.py
|
||||
# сохраняет payload.phone КАК ПРИСЛАЛИ (намеренно -- полная
|
||||
# E.164-нормализация вне scope MVP, см. lead.py::_PHONE_PATTERN),
|
||||
# т.е. одна и та же строка может лежать в БД как "+7 999 123-45-67"
|
||||
# ИЛИ "89991234567" ИЛИ "8 (999) 123-45-67". Точное `phone = :phone`
|
||||
# находит строку только если запрашивающий пришлёт БУКВАЛЬНО ТОТ ЖЕ
|
||||
# формат, каким когда-то ввёл номер -- почти никогда так. Раньше это
|
||||
# молча удаляло 0 строк и всё равно возвращало 200 "данные удалены":
|
||||
# для 152-ФЗ ложное подтверждение удаления хуже честной ошибки.
|
||||
# `regexp_replace(x, '\\D', '', 'g')` с ОБЕИХ сторон сравнения снимает
|
||||
# форматирование (пробелы/скобки/дефисы/+) и сравнивает голые цифры.
|
||||
# Параметр -- CAST(:phone AS text), НЕ конкатенация (psycopg v3 / SQL
|
||||
# injection convention, .claude/rules/backend.md).
|
||||
# _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL (см. _ru_phone_norm_sql
|
||||
# выше) снимают форматирование С ОБЕИХ сторон И схлопывают ведущую
|
||||
# '8' в '7' при 11 цифрах -- покрывает РОВНО RU 8-vs-7 trunk-префикс,
|
||||
# без усечения до "последних 10 цифр" (риск ложного совпадения с
|
||||
# номером другой страны -- см. докстринг helper'а). Номера иных
|
||||
# форматов/длин сравниваются как есть (просто не совпадут). Параметр --
|
||||
# CAST(:phone AS text), НЕ конкатенация значения (psycopg v3 / SQL
|
||||
# injection convention, .claude/rules/backend.md); сам SQL-текст
|
||||
# собран из СТАТИЧНЫХ фрагментов (_PHONE_*_NORM_SQL), в которых нет
|
||||
# ни одного значения параметра.
|
||||
ids_param = [str(i) for i in all_estimate_ids]
|
||||
result = db.execute(
|
||||
text(
|
||||
"""
|
||||
f"""
|
||||
DELETE FROM trade_in_leads
|
||||
WHERE estimate_id = ANY(CAST(:ids AS uuid[]))
|
||||
OR (
|
||||
CAST(:phone AS text) IS NOT NULL
|
||||
AND regexp_replace(phone, '\\D', '', 'g')
|
||||
= regexp_replace(CAST(:phone AS text), '\\D', '', 'g')
|
||||
AND {_PHONE_COLUMN_NORM_SQL} = {_PHONE_PARAM_NORM_SQL}
|
||||
)
|
||||
"""
|
||||
),
|
||||
|
|
|
|||
|
|
@ -139,25 +139,51 @@ def test_erase_by_phone_only_touches_only_leads() -> None:
|
|||
|
||||
|
||||
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
|
||||
"""Regression guard for the deep-review MEDIUM finding (2026-08-06) +
|
||||
follow-up (RU 8-vs-7 trunk prefix): lead.py stores phone exactly as typed
|
||||
(no E.164 normalization, by design), so a differently-formatted-but-
|
||||
same-number erasure request must still match, AND the RU '8...' vs
|
||||
'+7...' trunk-prefix pair must collapse to the same canonical value. 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."""
|
||||
Both sides must go through the SAME normalization (_PHONE_COLUMN_NORM_SQL
|
||||
/ _PHONE_PARAM_NORM_SQL, see _ru_phone_norm_sql), 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
|
||||
# The comparison uses EXACTLY the two module-level normalized fragments
|
||||
# (not a hand-rolled inline duplicate) -- pins that both sides go through
|
||||
# the SAME normalization function, not two independently-drifting copies.
|
||||
col_norm = data_erasure._PHONE_COLUMN_NORM_SQL
|
||||
param_norm = data_erasure._PHONE_PARAM_NORM_SQL
|
||||
assert f"{col_norm} = {param_norm}" in sql
|
||||
# RU trunk-prefix collapse present on BOTH sides (11 digits, leading '8' -> '7').
|
||||
assert sql.count("length(regexp_replace") == 2
|
||||
assert sql.count("= '8'") == 2
|
||||
assert sql.count("'7' ||") == 2
|
||||
assert "phone = :phone" not in sql # old literal-equality path must be GONE
|
||||
assert not re.search(r":\w+::", sql) # psycopg v3 CAST trap
|
||||
|
||||
|
||||
def test_ru_phone_norm_sql_only_ever_takes_static_expressions() -> None:
|
||||
"""`_ru_phone_norm_sql` is a query-STRUCTURE builder, not a data path --
|
||||
the two module-level constants are the ONLY call sites, and both pass a
|
||||
column name / CAST(:bind AS type), never an actual phone value. This
|
||||
pins that contract so a future call site can't accidentally splice a
|
||||
real phone string into the SQL text."""
|
||||
assert data_erasure._PHONE_COLUMN_NORM_SQL == data_erasure._ru_phone_norm_sql("phone")
|
||||
assert data_erasure._PHONE_PARAM_NORM_SQL == data_erasure._ru_phone_norm_sql(
|
||||
"CAST(:phone AS text)"
|
||||
)
|
||||
# column side references the column, never the bind param; param side is the reverse.
|
||||
assert ":phone" not in data_erasure._PHONE_COLUMN_NORM_SQL
|
||||
assert "CAST(:phone AS text)" in data_erasure._PHONE_PARAM_NORM_SQL
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -248,3 +274,105 @@ def test_real_erase_by_phone_finds_differently_formatted_number() -> None:
|
|||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
def test_real_erase_by_phone_finds_ru_trunk_prefix_variant() -> None:
|
||||
"""End-to-end on a real DB: the coordinator's exact follow-up gap
|
||||
(2026-08-06) -- a lead stored as '89991234567' (leading '8') must be
|
||||
found and deleted when the erasure requester supplies '+7 999 123-45-67'
|
||||
(leading '+7'). Pure digit-stripping does NOT close this: stripped, the
|
||||
two are '89991234567' vs '79991234567' -- different at digit 1. Only the
|
||||
explicit 11-digit '8'->'7' collapse in _ru_phone_norm_sql makes them
|
||||
equal. Counterfactual proven manually against this same DB (raw SQL,
|
||||
see PR discussion): WITHOUT the collapse, `regexp_replace` alone finds 0
|
||||
rows for this exact pair."""
|
||||
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()
|
||||
|
||||
# Counterfactual: plain digit-stripping (the PRE-follow-up fix) does NOT
|
||||
# match this pair -- proves the 8-vs-7 gap was real, not a strawman.
|
||||
digits_only_match = db.execute(
|
||||
_t(
|
||||
"SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid) "
|
||||
"AND regexp_replace(phone, '\\D', '', 'g') "
|
||||
"= regexp_replace(CAST(:phone AS text), '\\D', '', 'g')"
|
||||
),
|
||||
{"id": str(lead_id), "phone": "+7 999 123-45-67"},
|
||||
).scalar()
|
||||
assert digits_only_match == 0, "digit-stripping alone must NOT match 8- vs 7-prefix"
|
||||
|
||||
out = data_erasure.erase_person_data(db, phone="+7 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()
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
def test_real_erase_by_phone_does_not_match_different_number() -> None:
|
||||
"""Negative control: a number differing in even ONE significant digit
|
||||
must NOT be found -- proves the normalization is an exact-identity
|
||||
check, not a fuzzy/truncated match that could delete a STRANGER's data.
|
||||
Stored '89991234567' vs requested '+7 999 123-45-68' (last digit 7->8)
|
||||
-- same length, same RU-looking shape, one digit off -- zero rows."""
|
||||
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="+7 999 123-45-68")
|
||||
|
||||
assert out["trade_in_leads_deleted"] == 0, "one differing digit must NOT match"
|
||||
remaining = db.execute(
|
||||
_t("SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid)"),
|
||||
{"id": str(lead_id)},
|
||||
).scalar()
|
||||
assert remaining == 1, "row must survive an erasure request for a DIFFERENT number"
|
||||
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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue