"""§19 redaction tests (#960) — самый важный слой безопасности. Покрываем: каждый тип RU PII, hard-block is_confidential, allowlist-поведение (SafePayload как единственный вход), и шов RU-hosted провайдера (is_external=False). """ from __future__ import annotations import pytest from app.services.llm.redaction import ( RedactionRefusedError, SafePayload, scrub_safe_payload, scrub_text, ) # ── Per-PII-type scrub ──────────────────────────────────────────────────────── def test_scrub_phone_ru() -> None: out = scrub_text("Звоните +7 (912) 345-67-89 срочно") assert "[REDACTED:phone]" in out assert "912" not in out def test_scrub_phone_8_prefix() -> None: out = scrub_text("тел 8 912 345 67 89") assert "[REDACTED:phone]" in out assert "345" not in out def test_scrub_email() -> None: out = scrub_text("пишите ivan.petrov@example.ru пожалуйста") assert "[REDACTED:email]" in out assert "example.ru" not in out def test_scrub_snils() -> None: out = scrub_text("СНИЛС 112-233-445 95 в деле") assert "[REDACTED:snils]" in out assert "112-233-445" not in out def test_scrub_inn_12() -> None: out = scrub_text("ИНН физлица 500100732259 проверен") assert "[REDACTED:inn]" in out assert "500100732259" not in out def test_scrub_inn_10() -> None: out = scrub_text("ИНН юрлица 7707083893 в ЕГРЮЛ") assert "[REDACTED:inn]" in out assert "7707083893" not in out def test_scrub_passport() -> None: out = scrub_text("паспорт 6512 123456 выдан") assert "[REDACTED:passport]" in out assert "123456" not in out def test_scrub_fullname() -> None: out = scrub_text("ответственный Иванов Иван Иванович подписал") assert "[REDACTED:name]" in out assert "Иванович" not in out # ── #1207: regex-дыры в первичном scrub'е (P2-security) ───────────────────────── def test_scrub_phone_bare_11_digits_starting_with_7() -> None: """Copy-paste форма «79221234567» (11 цифр, без + и без 8) — самая частая дыра. _PHONE_RE требует «+7»/«\\b8» + разделитель → этот вариант пропускался и уходил в OpenAI как есть. _PHONE_BARE_RE закрывает дыру. """ out = scrub_text("позвоните 79221234567 договоримся") assert "[REDACTED:phone]" in out assert "79221234567" not in out def test_scrub_phone_bare_11_digits_starting_with_8() -> None: """Аналогично — «89221234567» без разделителей.""" out = scrub_text("моб 89221234567 в любое время") assert "[REDACTED:phone]" in out assert "89221234567" not in out def test_scrub_fullname_caps_format() -> None: """Стандарт ЕГРН/паспортных выписок «ИВАНОВ ИВАН ИВАНОВИЧ» — старый regex требовал строчные после первой буквы → каполная форма уходила как есть.""" out = scrub_text("собственник ИВАНОВ ИВАН ИВАНОВИЧ зарегистрирован") assert "[REDACTED:name]" in out assert "ИВАНОВИЧ" not in out def test_scrub_fullname_mixed_caps_and_titlecase() -> None: """Смешанная форма «ИВАНОВ Иван Иванович» — тоже должна срабатывать.""" out = scrub_text("по доверенности ИВАНОВ Иван Иванович подписал") assert "[REDACTED:name]" in out assert "ИВАНОВ" not in out def test_scrub_snils_bare_11_digits() -> None: """СНИЛС без разделителей «12345678901» — не покрывался ни phone (нет 7/8 префикса), ни ИНН (длина не 10/12).""" out = scrub_text("СНИЛС 12345678901 в личном деле") assert "[REDACTED:snils]" in out assert "12345678901" not in out def test_phone_bare_wins_over_snils_bare_for_7_prefix() -> None: """Конфликт _PHONE_BARE_RE vs _SNILS_BARE_RE: 11 цифр, начинаются с 7 → phone.""" out = scrub_text("79221234567") assert "[REDACTED:phone]" in out assert "[REDACTED:snils]" not in out def test_inn_10_digits_not_caught_as_snils_bare() -> None: """10-значный ИНН не должен попадать в _SNILS_BARE_RE (тот требует ровно 11).""" out = scrub_text("ИНН 7707083893 действующий") assert "[REDACTED:inn]" in out assert "[REDACTED:snils]" not in out def test_scrub_multiple_pii_in_one_text() -> None: out = scrub_text("Иванов Иван Иванович, +7 912 345 67 89, mail@x.ru") assert "[REDACTED:name]" in out assert "[REDACTED:phone]" in out assert "[REDACTED:email]" in out def test_scrub_preserves_safe_text() -> None: """Безопасный текст (без PII) не трогается.""" safe = "Зона Ц-1, максимальная этажность 16, процент застройки 60." assert scrub_text(safe) == safe def test_scrub_empty_string_noop() -> None: assert scrub_text("") == "" # ── Hard-block: is_confidential ─────────────────────────────────────────────── def test_confidential_payload_refused_for_external() -> None: """is_confidential=True + external провайдер → RedactionRefusedError (НЕ отправляем).""" payload = SafePayload(text="секретная аналитика", is_confidential=True) with pytest.raises(RedactionRefusedError): scrub_safe_payload(payload, is_external=True) def test_non_confidential_payload_passes_external() -> None: """is_confidential=False → проходит (и скрабится).""" payload = SafePayload(text="публичный текст про зону Ц-1", is_confidential=False) out = scrub_safe_payload(payload, is_external=True) assert out.text == "публичный текст про зону Ц-1" assert out.is_confidential is False # ── Scrub применяется к text И fields на external-пути ───────────────────────── def test_scrub_safe_payload_scrubs_text_and_fields() -> None: payload = SafePayload( text="контакт +7 912 345 67 89", fields={"note": "почта a@b.ru", "floors": 16, "code": "Ц-1"}, ) out = scrub_safe_payload(payload, is_external=True) assert "[REDACTED:phone]" in out.text assert "[REDACTED:email]" in out.fields["note"] # Не-строковые и безопасные значения сохранены. assert out.fields["floors"] == 16 assert out.fields["code"] == "Ц-1" def test_scrub_nested_fields() -> None: """Рекурсивный scrub вложенных list/dict в fields.""" payload = SafePayload(fields={"items": [{"phone": "+7 912 345 67 89"}, "a@b.ru"]}) out = scrub_safe_payload(payload, is_external=True) assert "[REDACTED:phone]" in out.fields["items"][0]["phone"] assert "[REDACTED:email]" in out.fields["items"][1] # ── RU-hosted seam: is_external=False ───────────────────────────────────────── def test_ru_hosted_provider_skips_scrub_and_allows_confidential() -> None: """is_external=False (будущий RU-hosted): scrub не обязателен, confidential проходит. Данные не покидают РФ → §19 не нарушается. Это задокументированный шов. """ payload = SafePayload( text="контакт +7 912 345 67 89", is_confidential=True, ) out = scrub_safe_payload(payload, is_external=False) # Не бросает, текст НЕ скраблен (RU-провайдер может получить как есть). assert out is payload assert "+7 912 345 67 89" in out.text # ── Allowlist contract: SafePayload — единственный вход ──────────────────────── def test_safe_payload_defaults_are_empty_and_non_confidential() -> None: """По умолчанию SafePayload пуст и не-конфиденциален — безопасный нейтральный старт.""" p = SafePayload() assert p.text == "" assert p.fields == {} assert p.is_confidential is False # ── #1640: INN context-guard — large bare numbers must NOT be redacted ──────── def test_large_sum_10_digits_not_redacted_without_inn_keyword() -> None: """Слитная 10-значная сумма (1 200 000 000 руб) без якоря «ИНН» — НЕ редактируется. Регрессия #1640: старый _INN_RE ловил любые 10/12 цифр → «1200000000» уходило в [REDACTED:inn]. Новый паттерн требует маркер ИНН/inn перед числом. """ text = "стоимость участка составила 1200000000 рублей по договору" out = scrub_text(text) assert out == text, f"bare large sum was incorrectly redacted: {out!r}" def test_large_sum_12_digits_not_redacted_without_inn_keyword() -> None: """12-значная слитная сумма без якоря — НЕ редактируется.""" text = "капитализация компании 123456789012 рублей" out = scrub_text(text) assert out == text def test_round_billion_not_redacted() -> None: """Круглые суммы без контекста — НЕ редактируются.""" for amount in ("5000000000", "1000000000", "9876543210"): out = scrub_text(f"сумма {amount} руб") assert "[REDACTED:inn]" not in out, f"amount {amount!r} was falsely redacted" def test_inn_with_keyword_is_redacted() -> None: """Валидный ИНН с явным маркером «ИНН» — редактируется.""" out = scrub_text("ИНН 7707083893 зарегистрирован в ЕГРЮЛ") assert "[REDACTED:inn]" in out assert "7707083893" not in out def test_inn_12_with_keyword_is_redacted() -> None: """Валидный 12-значный ИНН физлица с маркером — редактируется.""" out = scrub_text("ИНН физлица 500100732259 подтверждён") assert "[REDACTED:inn]" in out assert "500100732259" not in out def test_inn_keyword_latin_case_insensitive() -> None: """Маркер 'inn' (латиница, любой регистр) тоже активирует редакцию.""" out = scrub_text("inn: 7707083893") assert "[REDACTED:inn]" in out def test_inn_bad_checksum_with_keyword_not_redacted() -> None: """Число с маркером «ИНН», но не прошедшее checksum, НЕ редактируется. Предотвращает ложные срабатывания на «ИНН 1234567890» (неверная контрольная цифра). """ out = scrub_text("ИНН 1234567890 (некорректный)") assert "[REDACTED:inn]" not in out assert "1234567890" in out # ── #1640 follow-up: word-boundary guards ((? None: """Цифры ИНН как суффикс буквенно-цифрового токена НЕ редактируются. Регрессия: (? None: """Цифры ИНН как префикс буквенно-цифрового токена НЕ редактируются. Регрессия: старый (?!\\d) не блокировал буквенный суффикс — «7707083893more» совпадало, т.к. 'm' не цифра. Новый (?!\\w) закрывает alpha-суффикс. """ out = scrub_text("ИНН 7707083893more") assert "[REDACTED:inn]" not in out assert "7707083893" in out def test_inn_digits_embedded_in_alphanumeric_token_not_redacted() -> None: """ИНН-подобный блок цифр внутри алфавитно-цифрового токена НЕ редактируется. «ИНН ref7707083893xyz» — цифры с обеих сторон окружены word-char'ами. """ out = scrub_text("ИНН ref7707083893xyz") assert "[REDACTED:inn]" not in out def test_inn_alpha_prefix_on_12digit_block_not_redacted() -> None: """12-значный валидный ИНН с буквенным префиксом (без пробела) НЕ редактируется. «ИНН ABC500100732259» — 'C' является word-char, (? None: """Валидный ИНН, отделённый знаком препинания (не word-char), редактируется. Двоеточие, запятая, скобка — не \\w → (? None: """Валидный ИНН в конце строки без символов после редактируется. Конец строки — не \\w → (?!\\w) пропускает. Базовый smoke. """ out = scrub_text("ИНН 7707083893") assert "[REDACTED:inn]" in out assert "7707083893" not in out