"""§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 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