feat(security): détection dialogues système Windows + fail-closed
Nouveau module system_dialog_guard.py : - Détection UAC, CredUI, SmartScreen, Defender, Driver install - Multi-signal (ClassName UIA, process, title FR/EN, parent_path) - Faux positifs validés (OSIRIS, OBSIUS, MEDSPHERE, Chrome, Excel) Intégration dans executor.py et policy.py : - 6 points de décision (avant click/type/key_combo, VLM, policy) - Pause supervisée au lieu de clic aveugle - Fail-closed en cas d'exception (P0-D audit) - Notification systray + remontée serveur Fix mock test policy engine pour compat _system_dialog_pause=None. 39 + 5 tests unitaires. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -122,6 +122,7 @@ class TestPolicyEngine:
|
||||
def _make_engine(self):
|
||||
from agent_v0.agent_v1.core.policy import PolicyEngine
|
||||
executor = MagicMock()
|
||||
executor._system_dialog_pause = None
|
||||
return PolicyEngine(executor), executor
|
||||
|
||||
def test_premier_essai_popup_fermee_retry(self):
|
||||
|
||||
391
tests/unit/test_system_dialog_guard.py
Normal file
391
tests/unit/test_system_dialog_guard.py
Normal file
@@ -0,0 +1,391 @@
|
||||
# tests/unit/test_system_dialog_guard.py
|
||||
"""
|
||||
Tests du garde-fou sécurité : détection des dialogues système Windows critiques.
|
||||
|
||||
Objectif : garantir que Léa REFUSE de cliquer automatiquement sur un
|
||||
UAC / CredUI / SmartScreen (vecteur d'attaque ransomware).
|
||||
|
||||
Philosophie :
|
||||
- Faux positif tolérable (pause pour rien).
|
||||
- Faux négatif catastrophique (clic UAC).
|
||||
- Les tests privilégient la sécurité : tout dialogue suspect DOIT matcher.
|
||||
|
||||
Cf. agent_v0/agent_v1/core/system_dialog_guard.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_v0.agent_v1.core.system_dialog_guard import (
|
||||
SystemDialogCategory,
|
||||
SystemDialogDetection,
|
||||
is_system_dialog,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UAC (Contrôle de compte d'utilisateur) — le danger le plus grave
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestUACDetection:
|
||||
"""Un UAC qui n'est PAS détecté = vecteur d'attaque ransomware."""
|
||||
|
||||
def test_uac_via_class_name_exact(self):
|
||||
"""ClassName UIA du Consent.exe."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": "$$$Secure UAP Dummy Window Class$$$"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
assert d.matched_signal == "class_name"
|
||||
|
||||
def test_uac_via_class_name_case_variation(self):
|
||||
"""Robustesse à la casse (UIA peut varier)."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": "$$$SECURE UAP DUMMY WINDOW CLASS$$$"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_class_name_fuzzy_secure_uap(self):
|
||||
"""Détection souple si Microsoft ajoute un suffixe."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": "Secure UAP Dummy"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_process_consent_exe(self):
|
||||
"""Le process consent.exe signe un UAC, peu importe la classe."""
|
||||
d = is_system_dialog(uia_snapshot={"process_name": "consent.exe"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_process_consent_exe_with_path(self):
|
||||
"""Chemin complet doit être normalisé."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"process_name": r"C:\Windows\System32\consent.exe"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_app_name_in_window_info(self):
|
||||
"""psutil.name() (app_name côté agent) doit être reconnu."""
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Administrateur", "app_name": "consent.exe"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_title_fr(self):
|
||||
"""Titre français officiel."""
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Contrôle de compte d'utilisateur"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_title_en(self):
|
||||
"""Titre anglais."""
|
||||
d = is_system_dialog(window_info={"title": "User Account Control"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_title_question_fr(self):
|
||||
"""Phrase caractéristique du prompt UAC."""
|
||||
d = is_system_dialog(
|
||||
window_info={
|
||||
"title": (
|
||||
"Voulez-vous autoriser cette application à apporter "
|
||||
"des modifications à votre appareil ?"
|
||||
)
|
||||
}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_title_question_en(self):
|
||||
d = is_system_dialog(
|
||||
window_info={
|
||||
"title": "Do you want to allow this app to make changes to your device?"
|
||||
}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
|
||||
def test_uac_via_parent_path_button_focused(self):
|
||||
"""Cas critique : le focus est sur le bouton 'Oui' de l'UAC.
|
||||
|
||||
Le ClassName du Button sera "Button", mais le parent est bien
|
||||
le Consent.exe. La détection DOIT matcher sur parent_path.
|
||||
"""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={
|
||||
"class_name": "Button",
|
||||
"name": "Oui",
|
||||
"control_type": "Button",
|
||||
"parent_path": [
|
||||
{"class_name": "$$$Secure UAP Dummy Window Class$$$", "name": ""},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.UAC
|
||||
assert "parent" in d.matched_signal.lower() or d.matched_signal == "parent_class_name"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CredUI (prompt mot de passe Windows)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCredUIDetection:
|
||||
"""Les prompts de mot de passe ne doivent JAMAIS recevoir de frappe auto."""
|
||||
|
||||
def test_credui_via_class_name(self):
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": "Credential Dialog Xaml Host"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
def test_credui_variant_no_space(self):
|
||||
"""Variante sans espaces."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": "CredentialDialogXamlHost"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
def test_credui_via_process_credentialuibroker(self):
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"process_name": "CredentialUIBroker.exe"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
def test_credui_via_process_credui(self):
|
||||
d = is_system_dialog(uia_snapshot={"process_name": "credui.exe"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
def test_credui_via_title_fr(self):
|
||||
d = is_system_dialog(window_info={"title": "Sécurité Windows"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
def test_credui_via_title_en(self):
|
||||
d = is_system_dialog(window_info={"title": "Windows Security"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
def test_credui_via_title_enter_creds(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Connectez-vous à votre compte"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.CREDUI
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SmartScreen
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSmartScreenDetection:
|
||||
|
||||
def test_smartscreen_via_process(self):
|
||||
d = is_system_dialog(uia_snapshot={"process_name": "smartscreen.exe"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.SMARTSCREEN
|
||||
|
||||
def test_smartscreen_via_title_fr(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Windows a protégé votre ordinateur"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.SMARTSCREEN
|
||||
|
||||
def test_smartscreen_via_title_en(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Windows protected your PC"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.SMARTSCREEN
|
||||
|
||||
def test_smartscreen_unknown_publisher(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Éditeur inconnu — Voulez-vous continuer ?"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.SMARTSCREEN
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Autres dialogues système
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestOtherSystemDialogs:
|
||||
|
||||
def test_defender_via_process(self):
|
||||
d = is_system_dialog(uia_snapshot={"process_name": "MsMpEng.exe"})
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.DEFENDER
|
||||
|
||||
def test_defender_threat_detected(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Menace détectée — Windows Defender"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
# Le regex "windows defender" gagne (listé en premier dans les patterns)
|
||||
assert d.category == SystemDialogCategory.DEFENDER
|
||||
|
||||
def test_driver_install(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Installer ce pilote logiciel ?"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
assert d.category == SystemDialogCategory.DRIVER
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FAUX POSITIFS À ÉVITER — les apps métier doivent passer
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestBusinessAppsPassThrough:
|
||||
"""Vérifier que les apps métier (OSIRIS, OBSIUS, MEDSPHERE, etc.)
|
||||
ne sont jamais confondues avec des dialogues système.
|
||||
|
||||
Un faux positif ici = workflow métier cassé.
|
||||
"""
|
||||
|
||||
def test_osiris_main_window(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "OSIRIS - Patient Dupont Jean", "app_name": "osiris.exe"}
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_osiris_confirmation_dialog(self):
|
||||
"""Un dialogue #32770 OSIRIS métier (confirmation sauvegarde) DOIT passer."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={
|
||||
"class_name": "#32770",
|
||||
"name": "Confirmation",
|
||||
"process_name": "osiris.exe",
|
||||
},
|
||||
window_info={"title": "Confirmation", "app_name": "osiris.exe"},
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_obsius_main_window(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "OBSIUS - Consultation", "app_name": "obsius.exe"}
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_medsphere(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "MEDSPHERE v4.2", "app_name": "medsphere.exe"}
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_chrome_with_security_word_in_page(self):
|
||||
"""Chrome avec 'sécurité' dans le titre de page = OK si app_name=chrome.exe.
|
||||
|
||||
NOTE : on accepte ici un faux positif théorique car 'Sécurité Windows'
|
||||
est matché par le regex titre. Un navigateur affichant cette exacte
|
||||
phrase en titre est possible. Compte tenu de l'asymétrie (clic UAC =
|
||||
catastrophe), on ACCEPTE cette pause supervisée de fait.
|
||||
"""
|
||||
# Cas neutre : titre Chrome sans 'Sécurité Windows'
|
||||
d = is_system_dialog(
|
||||
window_info={
|
||||
"title": "Google Chrome - Recherche Google",
|
||||
"app_name": "chrome.exe",
|
||||
}
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_excel_file(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Classeur1 - Excel", "app_name": "EXCEL.EXE"}
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_notepad(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Sans titre - Bloc-notes", "app_name": "notepad.exe"}
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cas limites — robustesse
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
|
||||
def test_no_input_returns_false(self):
|
||||
"""Aucune info disponible = pas de blocage (fail-open)."""
|
||||
d = is_system_dialog()
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_empty_strings(self):
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": "", "process_name": "", "name": ""},
|
||||
window_info={"title": "", "app_name": ""},
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_none_values(self):
|
||||
"""Les champs à None ne doivent pas planter."""
|
||||
d = is_system_dialog(
|
||||
uia_snapshot={"class_name": None, "process_name": None},
|
||||
window_info={"title": None, "app_name": None},
|
||||
)
|
||||
assert not d.is_system_dialog
|
||||
|
||||
def test_detection_to_dict_serializable(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "User Account Control"}
|
||||
)
|
||||
data = d.to_dict()
|
||||
assert data["is_system_dialog"] is True
|
||||
assert data["category"] == SystemDialogCategory.UAC
|
||||
assert data["reason"]
|
||||
|
||||
def test_unicode_title_fr_accents(self):
|
||||
"""Les accents ne cassent pas la détection."""
|
||||
d = is_system_dialog(
|
||||
window_info={"title": "Contrôle de compte d'utilisateur"}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
|
||||
def test_whitespace_title(self):
|
||||
d = is_system_dialog(
|
||||
window_info={"title": " Windows Security "}
|
||||
)
|
||||
assert d.is_system_dialog
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Intégration avec detect_current_system_dialog
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_detect_current_system_dialog_no_exception_linux():
|
||||
"""Sur Linux, UIA indispo mais l'appel ne doit jamais planter."""
|
||||
from agent_v0.agent_v1.core.system_dialog_guard import detect_current_system_dialog
|
||||
detection = detect_current_system_dialog()
|
||||
# On ne valide pas le résultat (dépend de la fenêtre active) —
|
||||
# juste qu'il n'y a pas d'exception.
|
||||
assert isinstance(detection, SystemDialogDetection)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
148
tests/unit/test_uac_guard_fail_closed_p0d.py
Normal file
148
tests/unit/test_uac_guard_fail_closed_p0d.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Tests du Fix P0-D : le garde-fou de dialogue système doit fail-closed.
|
||||
|
||||
Avant : si la détection lève une exception, on laissait passer (fail-open),
|
||||
ce qui contredisait le principe "faux positif tolérable, faux négatif
|
||||
catastrophique" — un UAC non détecté à cause d'un bug = vecteur ransomware.
|
||||
|
||||
Après : exception → pause supervisée + log critical + notification utilisateur
|
||||
+ flag `_system_dialog_pause` positionné avec category="unknown_check_failed".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def executor():
|
||||
"""Crée un ActionExecutorV1 sans déclencher l'init mss/pynput lourd.
|
||||
|
||||
On instancie via __new__ + on injecte les attributs minimaux nécessaires
|
||||
au test du garde-fou.
|
||||
"""
|
||||
from agent_v0.agent_v1.core.executor import ActionExecutorV1
|
||||
|
||||
exe = ActionExecutorV1.__new__(ActionExecutorV1)
|
||||
exe._system_dialog_pause = None
|
||||
exe._notification_manager = None # le @property notifier renverra _Noop
|
||||
return exe
|
||||
|
||||
|
||||
class TestUACGuardFailClosedP0D:
|
||||
"""Fix P0-D : exception dans la détection → pause supervisée."""
|
||||
|
||||
def test_exception_triggers_pause(self, executor, monkeypatch):
|
||||
"""Si detect_current_system_dialog lève, on pause au lieu de laisser passer."""
|
||||
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
|
||||
|
||||
def _boom(*_a, **_kw):
|
||||
raise RuntimeError("UIA backend down")
|
||||
|
||||
monkeypatch.setattr(guard_mod, "detect_current_system_dialog", _boom)
|
||||
|
||||
# Avant : pas de pause
|
||||
assert executor._system_dialog_pause is None
|
||||
|
||||
# Appel : doit retourner True (= "STOP") au lieu de False
|
||||
result = executor._check_and_pause_on_system_dialog(
|
||||
context="test_p0d_unit"
|
||||
)
|
||||
|
||||
assert result is True, (
|
||||
"fail-closed : exception → True (le caller doit stopper) "
|
||||
"et NON False comme avant le fix"
|
||||
)
|
||||
|
||||
def test_exception_sets_pause_state(self, executor, monkeypatch):
|
||||
"""Vérifie que _system_dialog_pause contient les bonnes infos."""
|
||||
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
|
||||
|
||||
def _boom(*_a, **_kw):
|
||||
raise ValueError("XPath error")
|
||||
|
||||
monkeypatch.setattr(guard_mod, "detect_current_system_dialog", _boom)
|
||||
|
||||
executor._check_and_pause_on_system_dialog(context="popup_handler")
|
||||
|
||||
pause = executor._system_dialog_pause
|
||||
assert pause is not None, "Le flag de pause doit être positionné"
|
||||
assert pause["category"] == "unknown_check_failed"
|
||||
assert pause["matched_signal"] == "exception"
|
||||
assert pause["matched_value"] == "ValueError"
|
||||
assert "XPath error" in pause["reason"]
|
||||
assert pause["context"] == "popup_handler"
|
||||
|
||||
def test_no_exception_no_dialog_returns_false(self, executor, monkeypatch):
|
||||
"""Si pas de dialogue système et pas d'exception → False (laisse passer)."""
|
||||
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
|
||||
|
||||
class _FakeDetection:
|
||||
is_system_dialog = False
|
||||
category = None
|
||||
matched_signal = None
|
||||
matched_value = None
|
||||
reason = None
|
||||
|
||||
monkeypatch.setattr(
|
||||
guard_mod,
|
||||
"detect_current_system_dialog",
|
||||
lambda *a, **k: _FakeDetection(),
|
||||
)
|
||||
|
||||
result = executor._check_and_pause_on_system_dialog(context="ok")
|
||||
assert result is False
|
||||
assert executor._system_dialog_pause is None
|
||||
|
||||
def test_dialog_detected_returns_true(self, executor, monkeypatch):
|
||||
"""Si UAC détecté légitimement → True + pause (comportement existant)."""
|
||||
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
|
||||
|
||||
class _UACDetection:
|
||||
is_system_dialog = True
|
||||
category = "uac_consent"
|
||||
matched_signal = "class_name"
|
||||
matched_value = "$$$Secure UAP Dummy Window Class$$$"
|
||||
reason = "UAC consent prompt detected"
|
||||
|
||||
monkeypatch.setattr(
|
||||
guard_mod,
|
||||
"detect_current_system_dialog",
|
||||
lambda *a, **k: _UACDetection(),
|
||||
)
|
||||
|
||||
result = executor._check_and_pause_on_system_dialog(context="uac_test")
|
||||
assert result is True
|
||||
assert executor._system_dialog_pause is not None
|
||||
assert executor._system_dialog_pause["category"] == "uac_consent"
|
||||
|
||||
def test_exception_notifies_user(self, executor, monkeypatch):
|
||||
"""L'exception doit déclencher une notification utilisateur."""
|
||||
from agent_v0.agent_v1.core import system_dialog_guard as guard_mod
|
||||
|
||||
def _boom(*_a, **_kw):
|
||||
raise OSError("UIA com error")
|
||||
|
||||
monkeypatch.setattr(guard_mod, "detect_current_system_dialog", _boom)
|
||||
|
||||
# Spy sur la notification
|
||||
notifications = []
|
||||
|
||||
class _SpyNotifier:
|
||||
def notify(self, title=None, message=None, **kwargs):
|
||||
notifications.append((title, message))
|
||||
|
||||
executor._notification_manager = _SpyNotifier()
|
||||
|
||||
executor._check_and_pause_on_system_dialog(context="spy_test")
|
||||
|
||||
assert len(notifications) >= 1, (
|
||||
"Une notification doit être envoyée à l'utilisateur"
|
||||
)
|
||||
title, message = notifications[0]
|
||||
assert "sécurité" in title.lower() or "lea" in title.lower()
|
||||
assert "garde-fou" in message.lower() or "pause" in message.lower()
|
||||
Reference in New Issue
Block a user