Assistant IA optionnel (analyse de sortie, diagnostic, generation de commande)

Nouvelle couche app/ai/ (provider OpenAI-compatible via urllib, redaction de
secrets, detection de commandes dangereuses, parametres, worker QThread) et son
integration dans chaque onglet terminal via un bouton "Assistant IA" (memes
conventions que le bouton Snippets existant) :

- Expliquer cette sortie / Diagnostiquer cette erreur / Proposer une solution /
  Poser une question... : utilisent la selection courante du terminal, affichees
  dans un panneau lateral (memoire uniquement, jamais journalise) reutilisant le
  splitter comme le panneau SFTP.
- Generer une commande : ne s'execute jamais automatiquement (Copier / Inserer
  dans le terminal sans Entree / Annuler), avec avertissement si la commande
  proposee correspond a un motif destructeur connu.
- Avant tout envoi : avis unique (persist) puis detection de secrets evidents
  (mot de passe, cle, jeton) avec choix de masquer, envoyer tel quel ou annuler.

Desactive par defaut, sans liste de fournisseur figee (URL + modele libres,
compatible OpenAI/OpenRouter/LLM local). La cle API passe exclusivement par
CredentialStore (jamais dans settings.json ni journalisee). Parametrable via
le nouveau menu "Assistant IA > Parametres..." (bouton "Tester la connexion").

Bug trouve par les tests avant commit : un panneau nouvellement insere dans un
QSplitter deja visible est affiche automatiquement par Qt, ce qui inversait le
premier clic sur "Afficher le panneau IA" ; corrige en le masquant explicitement
a sa creation.

9 nouveaux fichiers de test (provider, redaction, dangers, parametres, worker,
confirmations de securite, dialogue de commande, panneau, integration onglet) :
39 verifications, toutes au vert. Suite complete (31 fichiers) executee sans
regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Florian
2026-09-05 05:48:13 +02:00
parent 9216f636bd
commit 0b2ff38bfd
22 changed files with 2081 additions and 8 deletions

View File

@@ -0,0 +1,65 @@
"""AICommandDialog : extraction de la commande depuis la reponse de l'IA
(bloc de code s'il y en a un), et les 3 actions possibles (Copier / Inserer
dans le terminal / Annuler) - jamais d'execution automatique."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from PySide6.QtWidgets import QApplication
from app.dialogs.ai_command_dialog import AICommandDialog, extract_command
app = QApplication.instance() or QApplication(sys.argv)
print("=== 1. extract_command() : bloc de code present ===")
response = "Pour lister les gros fichiers :\n\n```bash\nfind / -size +100M\n```\n\nCela peut prendre du temps."
assert extract_command(response) == "find / -size +100M"
print(" OK")
print()
print("=== 2. extract_command() : bloc de code sans langage indique ===")
response = "```\ndf -h\n```"
assert extract_command(response) == "df -h"
print(" OK")
print()
print("=== 3. extract_command() : pas de bloc de code -> reponse entiere ===")
response = "Utilisez simplement : uptime"
assert extract_command(response) == "Utilisez simplement : uptime"
print(" OK")
print()
print("=== 4. Avertissement affiche seulement si la commande est dangereuse ===")
dialog_safe = AICommandDialog("```\ndf -h\n```")
assert dialog_safe.command == "df -h"
dialog_dangerous = AICommandDialog("```\nrm -rf /tmp/x\n```", dangerous_labels=["rm -rf (suppression recursive)"])
assert dialog_dangerous.command == "rm -rf /tmp/x"
print(" OK (pas de verification visuelle automatisee, juste la construction sans exception)")
print()
print("=== 5. 'Copier' place la commande (eventuellement corrigee) dans le presse-papiers ===")
dialog = AICommandDialog("```\necho bonjour\n```")
dialog.command_view.setPlainText("echo modifie")
dialog._copy()
assert QApplication.clipboard().text() == "echo modifie"
print(" OK")
print()
print("=== 6. 'Inserer dans le terminal' : accepte le dialogue, ne l'execute jamais ===")
dialog = AICommandDialog("```\nls -la\n```")
assert dialog.insert_requested is False
dialog._insert()
assert dialog.insert_requested is True
assert dialog.command == "ls -la"
assert dialog.result() == 1 # QDialog.Accepted
print(" OK")
print()
print("=== 7. 'Annuler' : rejette le dialogue, insert_requested reste False ===")
dialog = AICommandDialog("```\nls -la\n```")
dialog.cancel_btn.click()
assert dialog.insert_requested is False
assert dialog.result() == 0 # QDialog.Rejected
print(" OK")
print()
print("AI COMMAND DIALOG : TOUS LES TESTS OK")

57
tests/test_ai_danger.py Normal file
View File

@@ -0,0 +1,57 @@
"""Detection locale de commandes potentiellement destructives proposees par
l'assistant IA (jamais un blocage, seulement un avertissement)."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from app.ai.danger import is_dangerous, check_dangerous
print("=== 1. Commandes destructrices detectees ===")
dangerous_examples = [
"rm -rf /var/log/*",
"rm -fr /tmp/build",
"mkfs.ext4 /dev/sdb1",
"dd if=/dev/zero of=/dev/sda bs=1M",
"wipefs -a /dev/sdb",
"sudo shutdown -h now",
"sudo reboot",
"iptables -F",
"nft flush ruleset",
"DROP DATABASE production;",
"DELETE FROM users;",
"docker system prune -a --volumes",
"zpool destroy tank",
"lvremove /dev/vg0/lv0",
"vgremove vg0",
]
for cmd in dangerous_examples:
result = is_dangerous(cmd)
print(f" {'[X]' if result else '[ ]'} {cmd}")
assert result, f"aurait du etre detectee comme dangereuse : {cmd}"
print(" OK")
print()
print("=== 2. Commandes normales non signalees ===")
safe_examples = [
"df -h",
"systemctl status nginx",
"docker ps -a",
"ls -la /var/log",
"find /var -type f -printf '%s %p\\n' | sort -nr | head -20",
"SELECT * FROM users WHERE id = 1;",
"git rm --cached fichier.txt", # 'rm' present mais pas -rf : ne doit pas matcher
]
for cmd in safe_examples:
result = is_dangerous(cmd)
print(f" {'[X]' if result else '[ ]'} {cmd}")
assert not result, f"n'aurait pas du etre signalee : {cmd}"
print(" OK")
print()
print("=== 3. check_dangerous() renvoie une explication exploitable ===")
matches = check_dangerous("rm -rf /")
print(" correspondances:", [m.label for m in matches])
assert matches and "rm" in matches[0].label.lower()
print(" OK")
print()
print("AI DANGER : TOUS LES TESTS OK")

168
tests/test_ai_panel.py Normal file
View File

@@ -0,0 +1,168 @@
"""AIPanel : point d'entree unique des requetes IA d'un onglet - verifie que
la verification de securite (avis + secrets) est bien invoquee avant tout
envoi, que les reponses/erreurs mettent a jour la conversation, et qu'une
reponse perimee (generation obsolete) est ignoree."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from unittest.mock import patch
from PySide6.QtCore import QCoreApplication
from PySide6.QtWidgets import QApplication
from app.ai.provider import AIProvider, AIError
from app.ai.service import KIND_EXPLAIN, KIND_ASK
from app.dialogs.ai_prompts import MASK, SEND, CANCEL
from app.ui.ai_panel import AIPanel
app = QApplication.instance() or QApplication(sys.argv)
class FakeProvider(AIProvider):
def __init__(self, reply="reponse IA", fail_with=None):
self.reply = reply
self.fail_with = fail_with
self.chat_calls = []
def chat(self, messages, *, temperature=None):
self.chat_calls.append(messages)
if self.fail_with:
raise self.fail_with
return self.reply
def pump(ms=3000):
"""Laisse le worker en arriere-plan se terminer et livrer son signal."""
QCoreApplication.processEvents()
import time
deadline = time.time() + ms / 1000
while time.time() < deadline:
QCoreApplication.processEvents()
time.sleep(0.01)
NOTICE_OK = patch("app.ui.ai_panel.ensure_ai_notice_acknowledged", return_value=True)
SEND_ASIS = patch("app.ui.ai_panel.confirm_sensitive_content", return_value=SEND)
print("=== 1. Aucun fournisseur configure : message clair, pas de requete lancee ===")
panel = AIPanel(provider_factory=lambda: None)
panel.run_request(KIND_EXPLAIN, "erreur X", label="Expliquer cette sortie")
assert "non configure" in panel.conversation.toPlainText().lower()
assert panel._worker is None
print(" OK")
print()
print("=== 2. Avis de principe refuse : rien n'est envoye ===")
provider = FakeProvider()
panel = AIPanel(provider_factory=lambda: provider)
with patch("app.ui.ai_panel.ensure_ai_notice_acknowledged", return_value=False):
panel.run_request(KIND_EXPLAIN, "erreur X", label="Expliquer cette sortie")
assert provider.chat_calls == []
assert panel._worker is None
print(" OK")
print()
print("=== 3. Secret detecte + annulation : rien n'est envoye ===")
provider = FakeProvider()
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, patch("app.ui.ai_panel.confirm_sensitive_content", return_value=CANCEL):
panel.run_request(KIND_EXPLAIN, "password=abc123", label="Expliquer cette sortie")
assert provider.chat_calls == []
print(" OK")
print()
print("=== 4. Secret detecte + masquage : le texte envoye est redige ===")
provider = FakeProvider(reply="analyse")
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, patch("app.ui.ai_panel.confirm_sensitive_content", return_value=MASK):
panel.run_request(KIND_EXPLAIN, "password=abc123", label="Expliquer cette sortie")
pump()
sent_content = provider.chat_calls[0][-1]["content"]
print(" contenu envoye:", sent_content)
assert "abc123" not in sent_content
assert "[MASQUE]" in sent_content
assert panel._last_response == "analyse"
print(" OK")
print()
print("=== 5. Requete reussie : reponse affichee et memorisee (Copier reponse) ===")
provider = FakeProvider(reply="Tout va bien.")
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, SEND_ASIS:
panel.run_request(KIND_EXPLAIN, "sortie normale", label="Expliquer cette sortie")
pump()
assert "Tout va bien." in panel.conversation.toPlainText()
panel._copy_last_response()
assert QApplication.clipboard().text() == "Tout va bien."
print(" OK")
print()
print("=== 6. Erreur du fournisseur : affichee dans la conversation, pas d'exception ===")
provider = FakeProvider(fail_with=AIError("cle API invalide"))
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, SEND_ASIS:
panel.run_request(KIND_EXPLAIN, "sortie", label="Expliquer cette sortie")
pump()
assert "cle API invalide" in panel.conversation.toPlainText()
print(" OK")
print()
print("=== 7. Effacer la conversation : generation incrementee, contenu vide ===")
provider = FakeProvider(reply="reponse")
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, SEND_ASIS:
panel.run_request(KIND_EXPLAIN, "sortie", label="Expliquer cette sortie")
pump()
gen_before = panel._generation
panel.clear_conversation()
assert panel._generation == gen_before + 1
assert panel.conversation.toPlainText() == ""
assert panel._last_response == ""
print(" OK")
print()
print("=== 8. Une reponse d'une generation perimee est ignoree (annulation cooperative) ===")
provider = FakeProvider(reply="reponse tardive")
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, SEND_ASIS:
panel.run_request(KIND_EXPLAIN, "sortie", label="Expliquer cette sortie")
worker = panel._worker
panel.clear_conversation() # invalide la generation avant que le worker n'ait fini
worker.wait(3000)
QCoreApplication.processEvents()
assert "reponse tardive" not in panel.conversation.toPlainText()
print(" OK")
print()
print("=== 9. Question libre (panneau) : contexte joint, question affichee ===")
provider = FakeProvider(reply="42")
panel = AIPanel(provider_factory=lambda: provider)
panel.prompt_question(context="ligne de contexte")
assert panel.question_row.isVisible()
panel.question_edit.setText("pourquoi ?")
with NOTICE_OK, SEND_ASIS:
panel._send_question()
pump()
assert not panel.question_row.isVisible()
last_messages = provider.chat_calls[-1]
assert any("ligne de contexte" in m["content"] for m in last_messages)
assert last_messages[-1]["content"] == "pourquoi ?"
assert "42" in panel.conversation.toPlainText()
print(" OK")
print()
print("=== 10. Une requete deja en cours bloque la suivante (une a la fois) ===")
provider = FakeProvider(reply="lente")
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, SEND_ASIS:
panel.run_request(KIND_EXPLAIN, "sortie 1", label="Expliquer cette sortie")
first_worker = panel._worker
panel.run_request(KIND_EXPLAIN, "sortie 2", label="Expliquer cette sortie")
assert panel._worker is first_worker, "la seconde requete n'aurait pas du demarrer un nouveau worker"
assert "deja en cours" in panel.busy_label.text().lower()
pump()
print(" OK")
print()
print("AI PANEL : TOUS LES TESTS OK")

96
tests/test_ai_prompts.py Normal file
View File

@@ -0,0 +1,96 @@
"""Confirmations de securite avant envoi a l'assistant IA : avis de principe
affiche une seule fois (persist dans app_settings, redirige vers un fichier
temporaire), et verification des secrets a chaque envoi. QMessageBox est
simule (exec()/clickedButton() patches) : aucune boite modale reelle ne doit
bloquer les tests."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtWidgets import QApplication, QMessageBox
import app.app_settings as app_settings
from app.dialogs.ai_prompts import ensure_ai_notice_acknowledged, confirm_sensitive_content, MASK, SEND, CANCEL
app = QApplication.instance() or QApplication(sys.argv)
tmp = Path(tempfile.mkdtemp())
settings_redirect = patch.object(app_settings, "SETTINGS_FILE", tmp / "settings.json")
settings_redirect.start()
def click(button_text):
"""Simule un clic sur le bouton `button_text` d'une QMessageBox construite
avec addButton(), sans faire tourner de boucle d'evenements reelle."""
def fake_clicked(self):
for b in self.buttons():
if b.text() == button_text:
return b
return None
return fake_clicked
print("=== 1. Avis de principe : affiche la premiere fois, refus -> False ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=QMessageBox.Cancel) as mocked_exec:
result = ensure_ai_notice_acknowledged(None)
assert mocked_exec.called
assert result is False
assert app_settings.get_setting("ai_notice_acknowledged", False) is False
print(" OK")
print()
print("=== 2. Avis de principe : acceptation -> True, persiste ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=QMessageBox.Ok) as mocked_exec:
result = ensure_ai_notice_acknowledged(None)
assert mocked_exec.called
assert result is True
assert app_settings.get_setting("ai_notice_acknowledged", False) is True
print(" OK")
print()
print("=== 3. Avis de principe : deja accepte -> plus jamais affiche ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec") as mocked_exec:
result = ensure_ai_notice_acknowledged(None)
assert not mocked_exec.called, "la boite ne doit plus s'afficher une fois l'accord persiste"
assert result is True
print(" OK")
print()
print("=== 4. Aucun secret detecte : SEND direct, aucune boite affichee ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec") as mocked_exec:
result = confirm_sensitive_content(None, "systemctl status nginx")
assert not mocked_exec.called
assert result == SEND
print(" OK")
print()
print("=== 5. Secret detecte + 'Masquer automatiquement' -> MASK ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=0), \
patch("app.dialogs.ai_prompts.QMessageBox.clickedButton", click("Masquer automatiquement")):
result = confirm_sensitive_content(None, "password=Sup3rSecret!")
assert result == MASK
print(" OK")
print()
print("=== 6. Secret detecte + 'Voir le contenu' -> SEND (envoi tel quel) ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=0), \
patch("app.dialogs.ai_prompts.QMessageBox.clickedButton", click("Voir le contenu")):
result = confirm_sensitive_content(None, "password=Sup3rSecret!")
assert result == SEND
print(" OK")
print()
print("=== 7. Secret detecte + 'Annuler' -> CANCEL ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=0), \
patch("app.dialogs.ai_prompts.QMessageBox.clickedButton", click("Annuler")):
result = confirm_sensitive_content(None, "password=Sup3rSecret!")
assert result == CANCEL
print(" OK")
settings_redirect.stop()
print()
print("AI PROMPTS : TOUS LES TESTS OK")

157
tests/test_ai_provider.py Normal file
View File

@@ -0,0 +1,157 @@
"""OpenAICompatibleProvider : requete HTTP construite correctement, et gestion
des erreurs (401, 429, timeout, reseau, reponse invalide) - urllib.request.urlopen
est entierement mocke, aucun appel reseau reel."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import io
import json
import urllib.error
from unittest.mock import patch, MagicMock
from app.ai.provider import AIProvider, AIError, OpenAICompatibleProvider
def fake_response(payload: dict):
m = MagicMock()
m.read.return_value = json.dumps(payload).encode("utf-8")
m.__enter__.return_value = m
m.__exit__.return_value = False
return m
def make_http_error(code: int, body: bytes = b"") -> urllib.error.HTTPError:
return urllib.error.HTTPError(url="http://x/chat/completions", code=code, msg="err",
hdrs=None, fp=io.BytesIO(body))
provider = OpenAICompatibleProvider(
base_url="https://api.example.com/v1", api_key="sk-test", model="gpt-test", timeout=5, temperature=0.2,
)
print("=== 1. Requete construite correctement (URL, methode, en-tetes, corps) ===")
with patch("app.ai.provider.urllib.request.urlopen") as mocked:
mocked.return_value = fake_response({"choices": [{"message": {"content": "bonjour"}}]})
result = provider.chat([{"role": "user", "content": "salut"}])
request = mocked.call_args.args[0]
print(" URL:", request.full_url)
print(" methode:", request.get_method())
print(" Authorization:", request.get_header("Authorization"))
body = json.loads(request.data.decode("utf-8"))
print(" corps:", body)
assert request.full_url == "https://api.example.com/v1/chat/completions"
assert request.get_method() == "POST"
assert request.get_header("Authorization") == "Bearer sk-test"
assert body["model"] == "gpt-test"
assert body["temperature"] == 0.2
assert body["messages"] == [{"role": "user", "content": "salut"}]
assert result == "bonjour"
print(" OK")
print()
print("=== 2. Erreur 401 -> message clair mentionnant la cle API ===")
with patch("app.ai.provider.urllib.request.urlopen", side_effect=make_http_error(401)):
try:
provider.chat([{"role": "user", "content": "x"}])
ok = False
except AIError as exc:
ok = True
print(" message:", exc)
assert "401" in str(exc) and "cle" in str(exc).lower()
assert ok
print(" OK")
print()
print("=== 3. Erreur 429 -> message clair mentionnant la limitation ===")
with patch("app.ai.provider.urllib.request.urlopen", side_effect=make_http_error(429)):
try:
provider.chat([{"role": "user", "content": "x"}])
ok = False
except AIError as exc:
ok = True
print(" message:", exc)
assert "429" in str(exc)
assert ok
print(" OK")
print()
print("=== 4. Timeout -> message clair, pas une exception brute ===")
timeout_error = urllib.error.URLError(reason=TimeoutError("timed out"))
with patch("app.ai.provider.urllib.request.urlopen", side_effect=timeout_error):
try:
provider.chat([{"role": "user", "content": "x"}])
ok = False
except AIError as exc:
ok = True
print(" message:", exc)
assert "delai" in str(exc).lower() or "temps" in str(exc).lower()
assert ok
print(" OK")
print()
print("=== 5. Erreur reseau (DNS, connexion refusee) -> message clair ===")
network_error = urllib.error.URLError(reason="nom ou service inconnu")
with patch("app.ai.provider.urllib.request.urlopen", side_effect=network_error):
try:
provider.chat([{"role": "user", "content": "x"}])
ok = False
except AIError as exc:
ok = True
print(" message:", exc)
assert ok
print(" OK")
print()
print("=== 6. Reponse invalide (JSON incoherent) -> message clair, pas de KeyError brut ===")
with patch("app.ai.provider.urllib.request.urlopen") as mocked:
mocked.return_value = fake_response({"unexpected": "shape"})
try:
provider.chat([{"role": "user", "content": "x"}])
ok = False
except AIError as exc:
ok = True
print(" message:", exc)
assert ok
print(" OK")
print()
print("=== 7. Sans cle API : pas d'en-tete Authorization ===")
no_key_provider = OpenAICompatibleProvider(base_url="http://localhost:8080/v1", api_key="", model="local-model")
with patch("app.ai.provider.urllib.request.urlopen") as mocked:
mocked.return_value = fake_response({"choices": [{"message": {"content": "ok"}}]})
no_key_provider.chat([{"role": "user", "content": "x"}])
request2 = mocked.call_args.args[0]
assert request2.get_header("Authorization") is None
print(" OK (utile pour un LLM local sans authentification)")
print()
print("=== 8. Les 4 usages metier construisent les bons messages (via chat()) ===")
captured = []
class SpyProvider(AIProvider):
def chat(self, messages, *, temperature=None):
captured.append(messages)
return "reponse"
spy = SpyProvider()
spy.explain_output("erreur X")
spy.diagnose("erreur Y")
spy.propose_solution("erreur Z")
spy.generate_command("trouver les gros fichiers")
spy.ask("pourquoi ?", context="ligne de contexte")
print(" nombre d'appels a chat():", len(captured))
assert len(captured) == 5
assert "erreur X" in captured[0][-1]["content"]
assert "Cause probable" in captured[1][0]["content"] # structure imposee au system prompt de diagnose
assert "erreur Z" in captured[2][-1]["content"]
assert captured[3][-1]["content"] == "trouver les gros fichiers"
assert "ligne de contexte" in captured[4][1]["content"]
assert captured[4][-1]["content"] == "pourquoi ?"
print(" OK")
print()
print("AI PROVIDER : TOUS LES TESTS OK")

View File

@@ -0,0 +1,60 @@
"""Detection/redaction de secrets avant envoi a l'assistant IA."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from app.ai.redaction import detect_secrets, redact
print("=== 1. Cle privee PEM detectee ===")
text = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----"
found = detect_secrets(text)
print(" detecte:", [f.label for f in found])
assert any("privee" in f.label.lower() for f in found)
print(" OK")
print()
print("=== 2. Jeton Bearer detecte ===")
found = detect_secrets("Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz123456")
labels = [f.label for f in found]
print(" detecte:", labels)
assert any("bearer" in l.lower() for l in labels)
assert any("authorization" in l.lower() for l in labels)
print(" OK")
print()
print("=== 3. Cle AWS detectee ===")
found = detect_secrets("export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE")
assert any("aws" in f.label.lower() for f in found)
print(" OK")
print()
print("=== 4. password= / api_key= / token= detectes ===")
assert any("mot de passe" in f.label.lower() for f in detect_secrets("password=Sup3rSecret!"))
assert any("api" in f.label.lower() for f in detect_secrets("api_key=abcd1234"))
assert any("jeton" in f.label.lower() for f in detect_secrets("token: eyJhbGciOiJIUzI1NiJ9"))
print(" OK")
print()
print("=== 5. Chaine longue ressemblant a un secret ===")
found = detect_secrets("valeur=" + "a1b2c3d4e5f6g7h8i9j0" * 3)
assert any("longue" in f.label.lower() for f in found)
print(" OK")
print()
print("=== 6. Texte anodin : rien detecte ===")
assert detect_secrets("systemd[1]: nginx.service: Failed with result 'exit-code'") == []
assert detect_secrets("nginx: [emerg] bind() to 0.0.0.0:443 failed (98: Address already in use)") == []
print(" OK")
print()
print("=== 7. redact() masque sans lever, texte anodin inchange ===")
redacted = redact("password=abc123 et Bearer xyzxyzxyzxyzxyzxyz")
print(" resultat:", redacted)
assert "abc123" not in redacted
assert "xyzxyzxyzxyzxyzxyz" not in redacted
assert "[MASQUE]" in redacted
clean = "systemctl status nginx"
assert redact(clean) == clean
print(" OK")
print()
print("AI REDACTION : TOUS LES TESTS OK")

168
tests/test_ai_service.py Normal file
View File

@@ -0,0 +1,168 @@
"""AIRequestWorker : execution d'une requete IA dans un vrai QThread en
arriere-plan, jamais sur le thread Qt principal - avec dispatch correct selon
le type de requete et remontee des erreurs via signal plutot qu'exception."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import threading
from PySide6.QtCore import QCoreApplication
from PySide6.QtWidgets import QApplication
from app.ai.provider import AIProvider, AIError
from app.ai.service import (
AIRequestWorker, KIND_EXPLAIN, KIND_DIAGNOSE, KIND_SOLUTION, KIND_GENERATE, KIND_ASK, KIND_TEST,
)
app = QApplication.instance() or QApplication(sys.argv)
class RecordingProvider(AIProvider):
"""Enregistre la methode appelee et le thread d'execution, pour verifier
a la fois le dispatch correct et le fait que ca tourne hors du thread principal."""
def __init__(self, fail_with: Exception | None = None):
self.calls = []
self.threads = []
self.fail_with = fail_with
def chat(self, messages, *, temperature=None):
self.calls.append(("chat", messages))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-chat"
def explain_output(self, output):
self.calls.append(("explain_output", output))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-explain"
def diagnose(self, output):
self.calls.append(("diagnose", output))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-diagnose"
def propose_solution(self, output):
self.calls.append(("propose_solution", output))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-solution"
def generate_command(self, request):
self.calls.append(("generate_command", request))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-generate"
def ask(self, question, context=""):
self.calls.append(("ask", (question, context)))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-ask"
def run_worker(worker, timeout_ms=5000):
results = {}
def on_ok(text):
results["ok"] = text
def on_error(text):
results["error"] = text
worker.finished_ok.connect(on_ok)
worker.finished_error.connect(on_error)
worker.start()
worker.wait(timeout_ms)
# laisser la boucle d'evenements livrer les signaux en file d'attente (cross-thread)
QCoreApplication.processEvents()
return results
main_thread = threading.current_thread()
print("=== 1. Dispatch correct par 'kind' (explain/diagnose/solution/generate/ask/test) ===")
cases = [
(KIND_EXPLAIN, "explain_output", "sortie X", "reponse-explain"),
(KIND_DIAGNOSE, "diagnose", "erreur Y", "reponse-diagnose"),
(KIND_SOLUTION, "propose_solution", "probleme Z", "reponse-solution"),
(KIND_GENERATE, "generate_command", "trouver les gros fichiers", "reponse-generate"),
]
for kind, expected_method, payload, expected_reply in cases:
provider = RecordingProvider()
worker = AIRequestWorker(provider, kind, payload)
results = run_worker(worker)
print(f" kind={kind!r} -> methode appelee: {provider.calls[0][0]!r}, resultat: {results!r}")
assert provider.calls == [(expected_method, payload)]
assert results.get("ok") == expected_reply
assert "error" not in results
print(" OK")
print()
print("=== 2. KIND_ASK transmet aussi le contexte ===")
provider = RecordingProvider()
worker = AIRequestWorker(provider, KIND_ASK, "pourquoi ?", context="ligne de contexte")
results = run_worker(worker)
print(" appel:", provider.calls[0], "resultat:", results)
assert provider.calls == [("ask", ("pourquoi ?", "ligne de contexte"))]
assert results.get("ok") == "reponse-ask"
print(" OK")
print()
print("=== 3. KIND_TEST appelle chat() avec un message minimal ===")
provider = RecordingProvider()
worker = AIRequestWorker(provider, KIND_TEST, "")
results = run_worker(worker)
print(" appel:", provider.calls[0], "resultat:", results)
assert provider.calls[0][0] == "chat"
assert results.get("ok") == "reponse-chat"
print(" OK")
print()
print("=== 4. AIError est capturee et remontee via finished_error, jamais levee ===")
provider = RecordingProvider(fail_with=AIError("cle API invalide"))
worker = AIRequestWorker(provider, KIND_EXPLAIN, "sortie")
results = run_worker(worker)
print(" resultat:", results)
assert results.get("error") == "cle API invalide"
assert "ok" not in results
print(" OK")
print()
print("=== 5. Exception inattendue capturee aussi (jamais de crash silencieux du thread) ===")
provider = RecordingProvider(fail_with=RuntimeError("boum"))
worker = AIRequestWorker(provider, KIND_EXPLAIN, "sortie")
results = run_worker(worker)
print(" resultat:", results)
assert "error" in results and "boum" in results["error"]
print(" OK")
print()
print("=== 6. Type de requete inconnu -> erreur claire, pas de plantage ===")
provider = RecordingProvider()
worker = AIRequestWorker(provider, "kind-inexistant", "x")
results = run_worker(worker)
print(" resultat:", results)
assert "error" in results and "inconnu" in results["error"].lower()
print(" OK")
print()
print("=== 7. L'execution se fait bien dans un thread separe du thread principal ===")
provider = RecordingProvider()
worker = AIRequestWorker(provider, KIND_EXPLAIN, "sortie")
run_worker(worker)
print(" thread principal:", main_thread, "thread d'execution:", provider.threads[0])
assert provider.threads[0] is not main_thread
assert isinstance(worker, __import__("PySide6.QtCore", fromlist=["QThread"]).QThread)
print(" OK")
print()
print("AI SERVICE : TOUS LES TESTS OK")

99
tests/test_ai_settings.py Normal file
View File

@@ -0,0 +1,99 @@
"""Configuration de l'assistant IA : parametres non sensibles persistes via
app_settings (redirige vers un fichier temporaire pour ne jamais toucher les
vrais reglages), cle API exclusivement via CredentialStore, et construction du
fournisseur a partir des deux."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from unittest.mock import patch
from app.credentials import CredentialStore
from app.ai.settings import (
AISettings, load_ai_settings, save_ai_settings, load_api_key, save_api_key,
build_provider_from_settings, AI_API_KEY_CREDENTIAL, DEFAULT_BASE_URL,
)
from app.ai.provider import OpenAICompatibleProvider
import app.app_settings as app_settings
class FakeCredentialStore(CredentialStore):
def __init__(self):
self._data = {}
def is_available(self):
return True
def set_secret(self, key, secret):
self._data[key] = secret
return True
def get_secret(self, key):
return self._data.get(key)
def delete_secret(self, key):
self._data.pop(key, None)
tmp = Path(tempfile.mkdtemp())
settings_redirect = patch.object(app_settings, "SETTINGS_FILE", tmp / "settings.json")
settings_redirect.start()
print("=== 1. Valeurs par defaut : desactive, pas de secret expose ===")
default = load_ai_settings()
print(" ", default)
assert default.enabled is False
assert default.base_url == DEFAULT_BASE_URL
print(" OK")
print()
print("=== 2. Sauvegarde/rechargement des parametres non sensibles ===")
settings = AISettings(enabled=True, provider_name="OpenRouter", base_url="https://openrouter.ai/api/v1",
model="anthropic/claude", timeout=45.0, temperature=0.7)
save_ai_settings(settings)
reloaded = load_ai_settings()
print(" ", reloaded)
assert reloaded == settings
print(" OK")
print()
print("=== 3. La cle API ne transite jamais par app_settings (verification du fichier disque) ===")
raw = (tmp / "settings.json").read_text(encoding="utf-8")
assert "sk-" not in raw
assert "api_key" not in raw.lower() or "ai_api" not in raw.lower() # aucune cle presente, seul le nom du champ pourrait
print(" contenu persiste:", raw)
print(" OK")
print()
print("=== 4. Cle API : lecture/ecriture via CredentialStore uniquement ===")
store = FakeCredentialStore()
assert load_api_key(store) == ""
save_api_key(store, "sk-test-secret")
assert load_api_key(store) == "sk-test-secret"
assert store.get_secret(AI_API_KEY_CREDENTIAL) == "sk-test-secret"
save_api_key(store, "") # vider la cle -> supprime l'entree
assert load_api_key(store) == ""
print(" OK")
print()
print("=== 5. build_provider_from_settings() : None si desactive ===")
save_ai_settings(AISettings(enabled=False))
assert build_provider_from_settings(store) is None
print(" OK")
print()
print("=== 6. build_provider_from_settings() : fournisseur pret si active ===")
save_ai_settings(AISettings(enabled=True, base_url="https://api.example.com/v1", model="gpt-x"))
save_api_key(store, "sk-live")
provider = build_provider_from_settings(store)
assert isinstance(provider, OpenAICompatibleProvider)
assert provider.base_url == "https://api.example.com/v1"
assert provider.model == "gpt-x"
assert provider.api_key == "sk-live"
print(" OK")
settings_redirect.stop()
print()
print("AI SETTINGS : TOUS LES TESTS OK")

View File

@@ -0,0 +1,160 @@
"""Integration de l'assistant IA dans un onglet terminal : menu (active/desactive
selon la selection), panneau lateral cree a la demande dans le splitter (meme
mecanisme que le panneau SFTP), et generation de commande (jamais executee
automatiquement, avec avertissement si dangereuse).
LocalTerminalConnection.start() est neutralisee comme dans test_snippets.py :
aucun sous-processus reel, send() depose seulement les octets dans une file."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from unittest.mock import patch
from PySide6.QtCore import QCoreApplication
from PySide6.QtWidgets import QApplication
from app.local_terminal import LocalTerminalConnection
from app.ai.provider import AIProvider
from app.dialogs.ai_prompts import SEND
from app.ui.ai_panel import AIPanel
from app.ui.tabs import LocalTerminalTabWidget
app = QApplication.instance() or QApplication(sys.argv)
NOTICE_OK = patch("app.ui.ai_panel.ensure_ai_notice_acknowledged", return_value=True)
SEND_ASIS = patch("app.ui.ai_panel.confirm_sensitive_content", return_value=SEND)
PROMPTS_OK_IN_TABS = patch("app.ui.tabs.ensure_ai_notice_acknowledged", return_value=True)
SEND_ASIS_IN_TABS = patch("app.ui.tabs.confirm_sensitive_content", return_value=SEND)
class FakeProvider(AIProvider):
def __init__(self, reply="reponse"):
self.reply = reply
def chat(self, messages, *, temperature=None):
return self.reply
def pump(ms=3000):
import time
deadline = time.time() + ms / 1000
while time.time() < deadline:
QCoreApplication.processEvents()
time.sleep(0.01)
def make_tab(ai_provider_factory=None):
return LocalTerminalTabWidget(ai_provider_factory=ai_provider_factory)
print("=== 1. Menu IA : actions de selection desactivees sans selection ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
assert not tab.terminal.has_selection()
menu = tab._build_ai_menu()
labels = [a.text() for a in menu.actions() if a.text()]
print(" actions:", labels)
assert "Expliquer cette sortie" in labels
assert "Generer une commande..." in labels
for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution", "Poser une question..."):
action = next(a for a in menu.actions() if a.text() == text)
assert not action.isEnabled(), f"{text} devrait etre desactive sans selection"
generate_action = next(a for a in menu.actions() if a.text() == "Generer une commande...")
assert generate_action.isEnabled(), "Generer une commande ne necessite pas de selection"
tab.close_connection()
print(" OK")
print()
print("=== 2. Menu IA : actions de selection activees des qu'il y a une selection ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
tab.terminal.feed(b"erreur : connection refused\r\n")
tab.terminal._sel_anchor, tab.terminal._sel_focus = (0, 0), (10, 0)
assert tab.terminal.has_selection()
menu = tab._build_ai_menu()
for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution", "Poser une question..."):
action = next(a for a in menu.actions() if a.text() == text)
assert action.isEnabled(), f"{text} devrait etre active avec une selection"
tab.close_connection()
print(" OK")
print()
print("=== 3. 'Expliquer cette sortie' : cree le panneau IA dans le splitter et lance la requete ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
provider = FakeProvider(reply="Ceci indique une connexion refusee.")
tab = make_tab(ai_provider_factory=lambda: provider)
tab.terminal.feed(b"erreur : connection refused\r\n")
tab.terminal._sel_anchor, tab.terminal._sel_focus = (0, 0), (10, 0)
assert tab.ai_panel is None
with NOTICE_OK, SEND_ASIS:
tab._ai_explain()
pump()
assert isinstance(tab.ai_panel, AIPanel)
assert tab.splitter.indexOf(tab.ai_panel) >= 0
assert "connexion refusee" in tab.ai_panel.conversation.toPlainText().lower()
tab.close_connection()
print(" OK")
print()
print("=== 4. Basculer l'affichage du panneau IA (menu) ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab(ai_provider_factory=lambda: FakeProvider())
tab.show() # isVisible() se compose avec les ancetres : il en faut un reellement affiche
tab._toggle_ai_panel() # premiere fois : cree + affiche
assert tab.ai_panel is not None and tab.ai_panel.isVisible()
tab._toggle_ai_panel() # deuxieme fois : masque
assert not tab.ai_panel.isVisible()
tab.close_connection()
print(" OK")
print()
print("=== 5. Generer une commande : jamais auto-execute, insertion sans Entree ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
provider = FakeProvider(reply="Pour lister les gros fichiers :\n\n```bash\nfind / -size +100M\n```")
tab = make_tab(ai_provider_factory=lambda: provider)
with PROMPTS_OK_IN_TABS, SEND_ASIS_IN_TABS, \
patch("app.ui.tabs.QInputDialog.getMultiLineText", return_value=("trouver les gros fichiers", True)), \
patch("app.ui.tabs.AICommandDialog.exec", lambda self: (setattr(self, "insert_requested", True), 1)[1]):
tab._ai_generate_command()
pump()
sent = tab.connection._out_queue.get_nowait()
print(" commande inseree:", sent)
assert sent == b"find / -size +100M"
assert not sent.endswith(b"\r") and not sent.endswith(b"\n")
assert tab.ai_btn.isEnabled() and tab.ai_btn.text() == "🤖 Assistant IA"
tab.close_connection()
print(" OK")
print()
print("=== 6. Generer une commande : annulee par l'utilisateur -> rien envoye ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
provider = FakeProvider(reply="```bash\nrm -rf /\n```")
tab = make_tab(ai_provider_factory=lambda: provider)
with patch("app.ui.tabs.QInputDialog.getMultiLineText", return_value=("", False)):
tab._ai_generate_command()
assert tab.connection._out_queue.empty()
tab.close_connection()
print(" OK")
print()
print("=== 7. Sans fournisseur configure : message clair, pas de boite de saisie ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab(ai_provider_factory=lambda: None)
with patch("app.ui.tabs.QInputDialog.getMultiLineText") as mocked_input, \
patch("app.ui.tabs.QMessageBox.information") as mocked_info:
tab._ai_generate_command()
assert not mocked_input.called
assert mocked_info.called
tab.close_connection()
print(" OK")
print()
print("=== 8. Fermeture de l'onglet : le panneau IA est arrete proprement ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab(ai_provider_factory=lambda: FakeProvider())
tab._toggle_ai_panel()
tab.close_connection() # ne doit pas lever, ni bloquer indefiniment
print(" OK")
print()
print("AI TAB INTEGRATION : TOUS LES TESTS OK")