Files
rpa_vision_v3/tests/unit/test_session_cleaner.py
Dom 4f61741420
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 14s
security-audit / pip-audit (CVE dépendances) (push) Successful in 10s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 14s
tests / Tests sécurité (critique) (push) Has been skipped
feat: journée 17 avril — tests E2E validés, dashboard fleet+audit, VWB bridge, cleaner C2
Pipeline E2E complet validé :
  Capture VM → streaming → serveur → cleaner → replay → audit trail
  Mode apprentissage supervisé fonctionne (Léa échoue → humain → reprise)

Dashboard :
  - Cleanup 14→10 onglets (RCE supprimée)
  - Fleet : enregistrer/révoquer agents, tokens, ZIP pré-configuré téléchargeable
  - Audit trail MVP (/audit) : filtres, tableau, export CSV, conformité AI Act/RGPD
  - Formulaire Fleet simplifié (nom + email, machine_id auto)

VWB bridge Léa→VWB :
  - Compound décomposés en N steps (saisie + raccourci visibles)
  - Layout serpentin 3 colonnes (plus colonne verticale)
  - Badge OS 🪟/🐧, filtre OS retiré (admin Linux voit Windows)
  - Fix import SQLite readonly

Cleaner intelligent :
  - Descriptions lisibles (UIA/C2) + détection doublons
  - Logique C2 : UIElement identifié = jamais parasite
  - Patterns parasites resserrés
  - Message Léa : "Je n'y arrive pas, montrez-moi comment faire"

Config agent (INC-1 à INC-7) :
  - SERVER_URL + SERVER_BASE unifiés
  - RPA_OLLAMA_HOST séparé
  - allow_redirects=False sur POST
  - Middleware réécriture URL serveur

CI Gitea : fix token + Flask-SocketIO + ruff propre
Fleet endpoints : /agents/enroll|uninstall|fleet + agent_registry SQLite
Backup : script quotidien workflows.db + audit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 17:46:40 +02:00

656 lines
24 KiB
Python

"""Tests pour tools/session_cleaner.py — precision de la detection parasite.
Ces tests verifient que la detection parasite est assez fine pour les
logiciels metier hospitaliers (DPI, codage PMSI, facturation) ou les
interfaces sont complexes (onglets, dialogues, assistants).
Regle d'or : mieux vaut garder un parasite que supprimer un vrai clic.
"""
import json
import os
import sys
from pathlib import Path
from typing import Any, Dict, List
import pytest
# Ajouter le repertoire racine au path pour importer tools.session_cleaner
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tools.session_cleaner import (
_ACTIONABLE_TYPES,
_PARASITIC_WINDOW_PATTERNS,
_get_app_name,
_has_identified_ui_element,
_is_parasitic,
_is_stop_recording_event,
_is_systray_interaction,
_parse_actions,
)
# ---------------------------------------------------------------------------
# Helpers pour construire des evenements de test
# ---------------------------------------------------------------------------
def _make_event(
etype: str = "mouse_click",
window_title: str = "",
app_name: str = "",
button: str = "left",
pos: list = None,
keys: list = None,
text: str = "",
uia_name: str = "",
uia_parent_path: list = None,
ui_elements: list = None,
vision_ui_elements: list = None,
) -> Dict[str, Any]:
"""Construire un evenement minimal pour les tests.
Parametres supplementaires pour la logique C2/UIA :
- uia_name : nom de l'element UIA (bouton, champ, etc.)
- ui_elements : liste d'elements C2 detectes par le pipeline vision
- vision_ui_elements : idem, place dans vision_info.ui_elements
"""
inner: Dict[str, Any] = {"type": etype}
if window_title or app_name:
inner["window"] = {"title": window_title, "app_name": app_name}
if etype == "mouse_click":
inner["button"] = button
inner["pos"] = pos or [640, 400]
if etype in ("key_combo", "key_press"):
inner["keys"] = keys or []
if etype in ("text_input", "type"):
inner["text"] = text
if uia_name or uia_parent_path:
inner["uia_snapshot"] = {
"name": uia_name,
"control_type": "",
"parent_path": uia_parent_path or [],
}
if ui_elements is not None:
inner["ui_elements"] = ui_elements
if vision_ui_elements is not None:
inner["vision_info"] = {"ui_elements": vision_ui_elements}
return {"event": inner, "session_id": "test_sess", "machine_id": "test"}
def _wrap_session(actionable_events: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Entourer d'evenements non-exploitables pour simuler une vraie session."""
hb = {"event": {"type": "heartbeat"}, "session_id": "test_sess", "machine_id": "test"}
return [hb] + actionable_events + [hb]
# ---------------------------------------------------------------------------
# Bug 1 — Premier clic sur le bureau (Program Manager) pas parasite
# ---------------------------------------------------------------------------
class TestBug1ProgramManager:
"""Program Manager ne doit plus jamais etre marque parasite."""
def test_first_click_on_desktop_not_parasitic(self):
"""Premier clic avec window='Program Manager' → NOT parasite.
Cas reel : l'utilisateur demarre un workflow depuis le bureau
Windows en cliquant sur une icone ou la barre des taches.
"""
event = _make_event(
window_title="Program Manager",
app_name="explorer.exe",
pos=[640, 400],
)
assert not _is_parasitic(event, index=0, total=10)
def test_middle_click_on_program_manager_not_parasitic(self):
"""Clic milieu de workflow sur bureau → NOT parasite.
Choix pragmatique : meme si l'utilisateur clique dans le vide,
c'est a lui de decider via l'interface du cleaner. Pas de
suppression automatique.
"""
event = _make_event(
window_title="Program Manager",
app_name="explorer.exe",
pos=[640, 400],
)
# Index 5 sur 10 = milieu de session
assert not _is_parasitic(event, index=5, total=10)
def test_program_manager_not_in_patterns(self):
"""'program manager' ne doit pas figurer dans les patterns parasites."""
for pattern in _PARASITIC_WINDOW_PATTERNS:
assert "program manager" not in pattern.lower(), (
f"'program manager' ne doit pas etre dans _PARASITIC_WINDOW_PATTERNS "
f"(trouve dans '{pattern}')"
)
# ---------------------------------------------------------------------------
# Bug 2 — Derniers clics : pas de regle blanket "3 derniers"
# ---------------------------------------------------------------------------
class TestBug2LastEventsNotBlanketParasitic:
"""Les derniers clics ne doivent plus etre automatiquement parasites."""
def test_last_click_on_notepad_not_parasitic(self):
"""Dernier clic avec window='Bloc-notes' → NOT parasite.
Cas reel : l'utilisateur valide/sauvegarde dans son dernier clic.
L'ancienne regle supprimait systematiquement les 3 derniers clics.
"""
event = _make_event(
window_title="Bloc-notes",
app_name="Notepad.exe",
pos=[500, 300],
)
# Dernier evenement sur 10
assert not _is_parasitic(event, index=9, total=10)
def test_last_click_on_dpi_not_parasitic(self):
"""Dernier clic sur DPI urgences → NOT parasite.
Cas critique : le dernier clic est souvent 'Valider le codage'
ou 'Sauvegarder la fiche patient'.
"""
event = _make_event(
window_title="DPI Urgences - Validation du codage",
app_name="dpi.exe",
pos=[800, 600],
)
assert not _is_parasitic(event, index=9, total=10)
def test_second_to_last_not_parasitic(self):
"""Avant-dernier clic metier → NOT parasite."""
event = _make_event(
window_title="Facturation MCO - Saisie actes",
app_name="facturation.exe",
pos=[500, 400],
)
assert not _is_parasitic(event, index=8, total=10)
def test_last_click_on_lea_systray_is_parasitic_via_stop(self):
"""Dernier clic sur Lea RPA (pythonw.exe) → parasite via stop_recording.
L'utilisateur clique sur l'icone Lea dans la systray pour arreter.
"""
event = _make_event(
window_title="unknown_window",
app_name="pythonw.exe",
pos=[917, 522],
uia_name="Contexte",
)
assert _is_stop_recording_event(event, is_last_actionable=True)
def test_not_last_click_on_pythonw_not_stop(self):
"""Clic sur pythonw.exe qui n'est PAS le dernier → pas stop_recording."""
event = _make_event(
window_title="unknown_window",
app_name="pythonw.exe",
pos=[917, 522],
)
assert not _is_stop_recording_event(event, is_last_actionable=False)
def test_ctrl_shift_l_stop_is_parasitic(self):
"""key_combo Ctrl+Shift+L en dernier → parasite (arret explicite)."""
event = _make_event(
etype="key_combo",
window_title="Bloc-notes",
app_name="Notepad.exe",
keys=["ctrl", "shift", "l"],
)
assert _is_stop_recording_event(event, is_last_actionable=True)
def test_ctrl_shift_l_not_last_not_stop(self):
"""Ctrl+Shift+L au milieu de session → pas un arret."""
event = _make_event(
etype="key_combo",
window_title="Bloc-notes",
app_name="Notepad.exe",
keys=["ctrl", "shift", "l"],
)
assert not _is_stop_recording_event(event, is_last_actionable=False)
def test_ctrl_s_last_not_stop(self):
"""Ctrl+S en dernier → NOT stop_recording (c'est un Ctrl+S, pas Ctrl+Shift).
Sauvegarder est un geste metier, pas un arret.
"""
event = _make_event(
etype="key_combo",
window_title="Bloc-notes",
app_name="Notepad.exe",
keys=["ctrl", "s"],
)
# Ctrl+S n'a pas 'shift', donc pas un stop
assert not _is_stop_recording_event(event, is_last_actionable=True)
# ---------------------------------------------------------------------------
# Bug 3 — Pattern "assistant" trop large
# ---------------------------------------------------------------------------
class TestBug3AssistantPattern:
"""'assistant' ne doit plus etre un pattern parasite."""
def test_assistant_in_dpi_not_parasitic(self):
"""Clic avec window='Assistant de codage PMSI' → NOT parasite.
Les logiciels metier hospitaliers utilisent souvent 'Assistant'
dans leurs titres de fenetre.
"""
event = _make_event(
window_title="Assistant de codage PMSI",
app_name="dpi.exe",
pos=[600, 400],
)
assert not _is_parasitic(event, index=5, total=10)
def test_assistant_facturation_not_parasitic(self):
"""'Assistant facturation' → NOT parasite."""
event = _make_event(
window_title="Assistant facturation - Etape 2/4",
app_name="facturation.exe",
pos=[500, 300],
)
assert not _is_parasitic(event, index=3, total=10)
def test_assistant_saisie_not_parasitic(self):
"""'Assistant de saisie' → NOT parasite."""
event = _make_event(
window_title="Assistant de saisie des actes CCAM",
app_name="saisie.exe",
pos=[700, 500],
)
assert not _is_parasitic(event, index=4, total=10)
def test_assistant_not_in_patterns(self):
"""'assistant' ne doit pas figurer tel quel dans les patterns."""
for pattern in _PARASITIC_WINDOW_PATTERNS:
# "assistant" seul ne doit pas y etre ;
# "lea - rpa assistant" ou similaire est OK car specifique
if pattern == "assistant":
pytest.fail(
"'assistant' seul est trop large pour les logiciels metier. "
"Utiliser un pattern plus specifique si necessaire."
)
def test_lea_rpa_pattern_matches(self):
"""'Léa - RPA' est bien detecte comme parasite."""
event = _make_event(
window_title="Léa - RPA Vision",
app_name="pythonw.exe",
pos=[400, 300],
)
assert _is_parasitic(event, index=5, total=10)
# ---------------------------------------------------------------------------
# Systray detection
# ---------------------------------------------------------------------------
class TestSystrayDetection:
"""La detection d'interaction systray doit etre precise."""
def test_icones_cachees_is_systray(self):
"""Clic sur 'Afficher les icônes cachées' → systray."""
event = _make_event(
window_title="unknown_window",
app_name="explorer.exe",
pos=[1080, 773],
uia_name="Afficher les icônes cachées",
)
assert _is_systray_interaction(event)
def test_depassement_parent_is_systray(self):
"""Element dont le parent est 'Fenêtre de dépassement' → systray."""
event = _make_event(
window_title="Fenêtre de dépassement",
app_name="explorer.exe",
pos=[1026, 710],
uia_parent_path=[
{"name": "Bureau 1", "control_type": "volet"},
{"name": "Fenêtre de dépassement de capacité", "control_type": "volet"},
],
)
assert _is_systray_interaction(event)
def test_normal_button_not_systray(self):
"""Bouton normal dans une application → pas systray."""
event = _make_event(
window_title="Bloc-notes",
app_name="Notepad.exe",
pos=[500, 300],
uia_name="Enregistrer",
uia_parent_path=[
{"name": "Barre de menu", "control_type": "barre de menu"},
],
)
assert not _is_systray_interaction(event)
def test_no_uia_not_systray(self):
"""Pas de uia_snapshot → pas systray."""
event = _make_event(
window_title="Bloc-notes",
app_name="Notepad.exe",
pos=[500, 300],
)
assert not _is_systray_interaction(event)
# ---------------------------------------------------------------------------
# C2/UIA — signaux positifs : element identifie = jamais parasite
# ---------------------------------------------------------------------------
class TestC2UIAPositiveSignals:
"""Si C2 ou UIA identifie un element UI reel, le clic est preserve.
Principe : un clic sur un bouton/champ/onglet identifie par nom dans
UIA ou par le pipeline C2 est un acte metier reel, meme si la fenetre
a un titre bizarre ou inconnu.
"""
def test_click_with_uia_element_never_parasitic(self):
"""Clic avec uia_snapshot.name='Enregistrer' → NOT parasite.
Meme si la fenetre a un titre etrange, un element UI nomme
est la preuve que c'est un vrai clic metier.
"""
event = _make_event(
window_title="Fenetre bizarre",
app_name="app_metier.exe",
pos=[600, 400],
uia_name="Enregistrer",
)
assert not _is_parasitic(event, index=5, total=10)
def test_click_with_uia_element_overrides_unknown_window(self):
"""Clic avec UIA identifie dans 'unknown_window' → NOT parasite.
Cas reel : certaines fenetres ont des titres non resolus par
l'agent mais l'UIA snapshot identifie quand meme l'element.
"""
event = _make_event(
window_title="unknown_window",
app_name="dpi.exe",
pos=[400, 300],
uia_name="Valider le codage",
)
assert not _is_parasitic(event, index=8, total=10)
def test_click_with_c2_ui_elements_never_parasitic(self):
"""Clic avec ui_elements (pipeline C2) → NOT parasite.
Quand le pipeline C2 enrichit l'evenement avec des elements
visuels detectes, c'est un signal fort de clic metier.
"""
event = _make_event(
window_title="unknown_window",
app_name="app_metier.exe",
pos=[600, 400],
ui_elements=[{"label": "Sauvegarder", "bbox": [580, 380, 620, 420]}],
)
assert not _is_parasitic(event, index=5, total=10)
def test_click_with_vision_info_ui_elements_never_parasitic(self):
"""Clic avec vision_info.ui_elements (format alternatif C2) → NOT parasite."""
event = _make_event(
window_title="unknown_window",
app_name="facturation.exe",
pos=[500, 350],
vision_ui_elements=[{"type": "button", "text": "Confirmer"}],
)
assert not _is_parasitic(event, index=6, total=10)
def test_uia_systray_still_parasitic(self):
"""Clic avec UIA identifie MAIS sur la systray → reste parasite.
Le shield C2/UIA ne s'applique pas a la systray. Un clic sur
'Afficher les icônes cachées' est toujours parasite meme si
l'UIA a identifie l'element.
"""
event = _make_event(
window_title="unknown_window",
app_name="explorer.exe",
pos=[1080, 773],
uia_name="Afficher les icônes cachées",
)
# _is_parasitic retourne False (UIA identifie + pas pattern fenetre)
# mais _is_systray_interaction le rattrape
assert _is_systray_interaction(event)
def test_right_click_with_uia_still_parasitic(self):
"""Clic droit avec UIA identifie → reste parasite.
Les clics droit sont un signal dur — meme si l'UIA a identifie
un element, un clic droit n'est jamais un clic metier exploitable.
"""
event = _make_event(
window_title="Bloc-notes",
app_name="Notepad.exe",
pos=[500, 300],
button="right",
uia_name="Zone de texte",
)
assert _is_parasitic(event, index=5, total=10)
class TestC2AppNameShield:
"""Si l'app est une application metier connue, les clics sont preserves."""
def test_click_in_known_app_not_parasitic(self):
"""Clic dans app_name='Notepad.exe' sans UIA → NOT parasite.
Une vraie application (pas explorer, pas pythonw) est une app
metier — ses clics sont legitimes meme sans info UIA/C2.
"""
event = _make_event(
window_title="Sans titre",
app_name="Notepad.exe",
pos=[400, 300],
)
assert not _is_parasitic(event, index=5, total=10)
def test_click_in_dpi_not_parasitic(self):
"""Clic dans dpi.exe sans UIA → NOT parasite."""
event = _make_event(
window_title="DPI Urgences",
app_name="dpi.exe",
pos=[600, 400],
)
assert not _is_parasitic(event, index=3, total=10)
def test_click_in_searchhost_not_parasitic(self):
"""Clic dans SearchHost.exe → NOT parasite.
La recherche Windows (SearchHost.exe) est une application
normale, pas un process systeme a filtrer.
"""
event = _make_event(
window_title="Rechercher",
app_name="SearchHost.exe",
pos=[500, 300],
)
assert not _is_parasitic(event, index=1, total=10)
def test_click_in_explorer_no_uia_can_be_parasitic(self):
"""Clic dans explorer.exe sans UIA → peut etre parasite si pattern.
explorer.exe est un cas particulier : c'est a la fois le bureau
(Program Manager) et l'explorateur de fichiers. Sans UIA pour
distinguer, on laisse les patterns de fenetre decider.
"""
event = _make_event(
window_title="Fenêtre de dépassement de capacité",
app_name="explorer.exe",
pos=[1026, 710],
)
# explorer.exe est dans _NON_BUSINESS_APPS, donc pas de shield
# et le pattern "fenêtre de dépassement" matche → parasite
assert _is_parasitic(event, index=5, total=10)
def test_click_in_pythonw_no_shield(self):
"""Clic dans pythonw.exe → pas de shield (c'est l'agent Lea)."""
event = _make_event(
window_title="unknown_window",
app_name="pythonw.exe",
pos=[917, 522],
)
# pythonw.exe est dans _NON_BUSINESS_APPS, pas de shield app
# mais pas de pattern de fenetre non plus → pas parasite via _is_parasitic
# (sera attrape par _is_stop_recording_event si c'est le dernier)
assert not _is_parasitic(event, index=5, total=10)
class TestHasIdentifiedUIElement:
"""Tests unitaires pour _has_identified_ui_element()."""
def test_uia_name_detected(self):
"""uia_snapshot avec nom → element identifie."""
event = _make_event(uia_name="Bouton OK")
assert _has_identified_ui_element(event)
def test_uia_empty_name_not_detected(self):
"""uia_snapshot avec nom vide → pas d'element identifie."""
event = _make_event(uia_name="")
assert not _has_identified_ui_element(event)
def test_no_uia_not_detected(self):
"""Pas de uia_snapshot → pas d'element identifie."""
event = _make_event()
assert not _has_identified_ui_element(event)
def test_c2_ui_elements_detected(self):
"""ui_elements (C2) → element identifie."""
event = _make_event(ui_elements=[{"label": "Sauvegarder"}])
assert _has_identified_ui_element(event)
def test_vision_ui_elements_detected(self):
"""vision_info.ui_elements → element identifie."""
event = _make_event(vision_ui_elements=[{"type": "button"}])
assert _has_identified_ui_element(event)
def test_empty_ui_elements_not_detected(self):
"""ui_elements vide → pas d'element identifie."""
event = _make_event(ui_elements=[])
assert not _has_identified_ui_element(event)
class TestGetAppName:
"""Tests unitaires pour _get_app_name()."""
def test_app_name_from_window(self):
event = _make_event(app_name="Notepad.exe")
assert _get_app_name(event) == "Notepad.exe"
def test_no_app_name(self):
event = _make_event()
assert _get_app_name(event) == ""
def test_app_name_none_returns_empty(self):
"""app_name None → chaine vide."""
event = {"event": {"type": "mouse_click", "window": {"title": "Test", "app_name": None}}}
assert _get_app_name(event) == ""
# ---------------------------------------------------------------------------
# Test sur la vraie session de Dom
# ---------------------------------------------------------------------------
class TestRealSessionDom:
"""Verification sur la session E2E reelle de Dom.
Session: sess_20260417T133324_30c2d0
Workflow: clic Rechercher → texte → clic resultat → Bloc-notes → texte →
Ctrl+S → systray stop.
"""
SESSION_PATH = os.path.join(
os.path.dirname(__file__),
"..", "..",
"data", "training", "live_sessions", "windows_vm",
"sess_20260417T133324_30c2d0", "live_events.jsonl",
)
@pytest.fixture
def real_events(self):
"""Charger les evenements reels si disponibles."""
path = Path(self.SESSION_PATH).resolve()
if not path.is_file():
pytest.skip(f"Session reelle non disponible : {path}")
events = []
with open(path, encoding="utf-8") as f:
for line in f:
if line.strip():
events.append(json.loads(line))
return events
def test_real_session_no_false_positives(self, real_events):
"""Aucun clic metier ne doit etre marque parasite.
Les 7 premiers evenements exploitables sont du workflow reel :
- Clic Rechercher (taskbar)
- Texte dans la recherche
- Clic sur un resultat
- Clic Agrandir (Bloc-notes)
- Clic Nouvel onglet
- Texte 'bijour'
- Ctrl+S (sauvegarder)
Les 4 derniers sont l'arret (systray):
- Right-click systray
- Left-click icones cachees
- Right-click fenetre depassement
- Left-click menu Lea (pythonw.exe)
"""
# Utiliser _parse_actions avec un dossier bidon (pas de shots)
session_dir = Path(self.SESSION_PATH).parent
actions = _parse_actions(real_events, session_dir)
# 11 evenements exploitables au total
assert len(actions) == 11, f"Attendu 11 actions, obtenu {len(actions)}"
# Les 7 premiers doivent etre OK (pas parasites)
for idx, action in enumerate(actions[:7]):
assert not action["is_parasitic"], (
f"Action {idx} (evt {action['global_index']}) faussement marquee parasite : "
f"type={action['type']}, win={action['window_title']}"
)
# Les 4 derniers doivent etre parasites (arret enregistrement)
for idx, action in enumerate(actions[7:], start=7):
assert action["is_parasitic"], (
f"Action {idx} (evt {action['global_index']}) devrait etre parasite : "
f"type={action['type']}, win={action['window_title']}"
)
def test_real_session_parasitic_count(self, real_events):
"""4 parasites sur 11 — pas plus, pas moins."""
session_dir = Path(self.SESSION_PATH).parent
actions = _parse_actions(real_events, session_dir)
parasitic = [a for a in actions if a["is_parasitic"]]
ok = [a for a in actions if not a["is_parasitic"]]
assert len(parasitic) == 4, (
f"Attendu 4 parasites, obtenu {len(parasitic)} : "
+ ", ".join(f"evt{a['global_index']}({a['window_title'][:30]})" for a in parasitic)
)
assert len(ok) == 7, (
f"Attendu 7 OK, obtenu {len(ok)}"
)