feat(core): coordination quarantaine résiduelle NIR/TEL décochés (P1-2/F-4)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 10:10:07 +02:00
parent b15d0da141
commit 2a3aab117d
2 changed files with 239 additions and 7 deletions

View File

@@ -182,6 +182,138 @@ class TestRescanQuarantine:
assert mgr.has_full_quarantine("doc_leak")
# === Tests F4 : patterns résiduels gated par catégorie désactivée ===
class TestResidualPatternsGating:
"""F-4 (P1-2) — `_build_residual_patterns(disabled)` : une catégorie
décochée ne doit pas déclencher la quarantaine résiduelle, ni directement,
ni via le pattern résiduel d'une autre catégorie (piège NIR ⇄ TEL)."""
def _labels(self, patterns):
return {label for _pat, label in patterns}
@staticmethod
def _residual_count(text, disabled):
"""Reproduit EXACTEMENT le calcul du call-site (process_pdf) :
seul le scan TEL voit le texte pré-masqué ; EMAIL/IBAN/NIR voient
le texte ORIGINAL."""
from anonymizer_core_refactored_onnx import (
_build_residual_patterns,
_residual_premask_text,
)
patterns = _build_residual_patterns(disabled)
tel_text = _residual_premask_text(text, disabled)
total = 0
for pat, label in patterns:
scan = tel_text if label == "TEL" else text
total += len(pat.findall(scan))
return total
def test_default_set_includes_all_labels(self) -> None:
"""Aucune catégorie désactivée → NIR, EMAIL, IBAN, TEL tous présents."""
from anonymizer_core_refactored_onnx import _build_residual_patterns
labels = self._labels(_build_residual_patterns(set()))
assert {"NIR", "EMAIL", "IBAN", "TEL"}.issubset(labels)
def test_nir_disabled_drops_nir_keeps_others(self) -> None:
"""NIR décoché → NIR absent, EMAIL/IBAN toujours présents."""
from anonymizer_core_refactored_onnx import _build_residual_patterns
labels = self._labels(_build_residual_patterns({"NIR"}))
assert "NIR" not in labels
assert "EMAIL" in labels
assert "IBAN" in labels
def test_tel_disabled_drops_tel(self) -> None:
"""TEL décoché → TEL absent."""
from anonymizer_core_refactored_onnx import _build_residual_patterns
labels = self._labels(_build_residual_patterns({"TEL"}))
assert "TEL" not in labels
def test_nir_disabled_tel_does_not_match_nir_in_clear(self) -> None:
"""Piège F-4 : NIR décoché laissé en clair → le pré-masquage SCOPÉ-TEL
empêche le pattern TEL de matcher le bloc central de chiffres du NIR.
Le NIR-pattern est retiré du set et EMAIL/IBAN ne matchent pas des
chiffres nus → décompte résiduel global == 0 pour ce NIR en clair."""
from anonymizer_core_refactored_onnx import (
_build_residual_patterns,
_residual_premask_text,
)
nir_en_clair = "1 85 05 74 123 456 78"
disabled = {"NIR"}
# Le pattern TEL appliqué au texte pré-masqué → 0 match.
patterns = _build_residual_patterns(disabled)
tel_pat = next(pat for pat, label in patterns if label == "TEL")
premasked = _residual_premask_text(nir_en_clair, disabled)
assert tel_pat.findall(premasked) == []
# Décompte résiduel global (logique call-site, TEL-scopé) == 0.
total = self._residual_count(nir_en_clair, disabled)
assert total == 0, (
f"NIR décoché ne doit pas déclencher la quarantaine, "
f"or {total} match(s) résiduel(s) sur {nir_en_clair!r}"
)
def test_nir_disabled_clear_iban_still_matches(self) -> None:
"""Fix 1 (régression) : le pré-masquage NIR est SCOPÉ au seul scan TEL.
Un IBAN en clair, avec NIR décoché, DOIT toujours déclencher le filet
IBAN résiduel — le pré-masquage ne doit PAS effacer ses groupes de
chiffres (sinon le backstop IBAN, toujours actif, serait affaibli)."""
from anonymizer_core_refactored_onnx import _build_residual_patterns
iban_clair = "FR76 3000 1007 9412 3456 7890 185"
disabled = {"NIR"}
# Le pattern IBAN (scanné sur le texte ORIGINAL) matche toujours.
patterns = _build_residual_patterns(disabled)
iban_pat = next(pat for pat, label in patterns if label == "IBAN")
assert iban_pat.findall(iban_clair), "le filet IBAN doit rester actif"
# Décompte résiduel global (logique call-site) ≥ 1.
total = self._residual_count(iban_clair, disabled)
assert total >= 1, (
f"IBAN en clair doit déclencher la quarantaine même NIR décoché, "
f"or {total} match(s)"
)
def test_residual_threshold_is_strict_zero_regardless_of_disabled(self) -> None:
"""Fix 2 (régression) : le seuil résiduel reste STRICT (0)
inconditionnellement. Un EMAIL en clair → 1 résidu, et 1 > 0 ⇒
quarantaine, même avec des catégories décochées (pas de relâchement
à 1 qui laisserait passer une fuite EMAIL/IBAN)."""
from anonymizer_core_refactored_onnx import SEUIL_RESCAN_RESIDUEL
assert SEUIL_RESCAN_RESIDUEL == 0
email_clair = "a@b.fr"
# Une catégorie est décochée mais le seuil effectif reste 0.
for disabled in (set(), {"NIR"}, {"NIR", "TEL"}):
total = self._residual_count(email_clair, disabled)
assert total == 1, (disabled, total)
# 1 résidu > seuil strict (0) ⇒ quarantaine déclenchée.
assert total > SEUIL_RESCAN_RESIDUEL
def test_nir_enabled_tel_behavior_unchanged(self) -> None:
"""Non-régression : NIR activé → le pré-masquage est l'identité et
un vrai téléphone est toujours détecté par le pattern TEL."""
from anonymizer_core_refactored_onnx import (
_build_residual_patterns,
_residual_premask_text,
)
tel = "06 12 34 56 78"
patterns = _build_residual_patterns(set())
text = _residual_premask_text(tel, set())
assert text == tel # identité quand rien n'est désactivé
tel_pat = next(pat for pat, label in patterns if label == "TEL")
assert tel_pat.findall(tel), "un vrai téléphone doit rester détecté"
# === Tests A : INDEX.md et errors.log ===========================
class TestQuarantineArtifacts: