erplibre/test/test_execute.py

235 lines
8.3 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import os
import unittest
from unittest.mock import patch
[FIX] execute : caviarder les clés d'API et les jetons Bearer Le filtre ne connaissait que trois noms de variable — mot de passe, secret, jeton — donc `OPENAI_API_KEY=` partait en clair dans le terminal, dans les journaux et dans toute sortie CI qui les capture. Un jeton d'en-tête échappait aux deux règles par construction : il ne porte ni nom d'option ni nom de variable, il suit le mot « Bearer ». Le filtre couvre maintenant `API_KEY` côté variables et `Authorization: Bearer|Basic` côté en-têtes, sans casse, la valeur allant jusqu'au premier blanc. Il protège au même titre la restauration de base, qui l'appelle sur six sorties. Reste le dernier rempart et non le premier : argv est lisible par tout compte de la machine, où aucun caviardage n'atteint. --- EN --- The filter knew only three variable names — password, secret, token — so `OPENAI_API_KEY=` went out in the clear to the terminal, to the logs and to any CI output capturing them. A header token escaped both rules by construction: it carries neither an option name nor a variable name, it follows the word "Bearer". The filter now covers `API_KEY` on the variable side and `Authorization: Bearer|Basic` on the header side, case-insensitively, the value running to the first blank. It protects database restore just as much, which calls it on six outputs. It stays the last line of defence, not the first: argv is readable by every account on the machine, where no redaction reaches. Assisted-by: Claude Opus 5
2026-09-09 04:29:46 -04:00
from script.execute.execute import Execute, redact_secrets
class TestExecuteInit(unittest.TestCase):
"""Test Execute class initialization."""
@patch("shutil.which")
def test_init_with_gnome_terminal(self, mock_which):
mock_which.side_effect = lambda x: (
"/usr/bin/gnome-terminal" if x == "gnome-terminal" else None
)
exe = Execute()
self.assertIn("gnome-terminal", exe.cmd_source_erplibre)
self.assertIn(".venv.erplibre", exe.cmd_source_erplibre)
self.assertIn("gnome-terminal", exe.cmd_source_default)
@patch("shutil.which")
def test_init_with_osascript(self, mock_which):
mock_which.side_effect = lambda x: (
"/usr/bin/osascript" if x == "osascript" else None
)
exe = Execute()
self.assertIn("osascript", exe.cmd_source_erplibre)
@patch("shutil.which", return_value=None)
def test_init_fallback_source(self, mock_which):
exe = Execute()
self.assertIn(".venv.erplibre/bin/activate", exe.cmd_source_erplibre)
self.assertEqual(exe.cmd_source_default, "")
class TestExecCommandLive(unittest.TestCase):
"""Test exec_command_live method."""
def setUp(self):
with patch("shutil.which", return_value=None):
self.exe = Execute()
def test_simple_command_returns_zero(self):
result = self.exe.exec_command_live(
"echo hello",
source_erplibre=False,
quiet=True,
)
self.assertEqual(result, 0)
def test_failing_command_returns_nonzero(self):
result = self.exe.exec_command_live(
"exit 42",
source_erplibre=False,
quiet=True,
)
self.assertEqual(result, 42)
def test_return_status_and_output(self):
status, output = self.exe.exec_command_live(
"echo hello",
source_erplibre=False,
quiet=True,
return_status_and_output=True,
)
self.assertEqual(status, 0)
self.assertEqual(output, ["hello"])
def test_return_status_and_output_multiline(self):
status, output = self.exe.exec_command_live(
"echo -e 'line1\nline2\nline3'",
source_erplibre=False,
quiet=True,
return_status_and_output=True,
)
self.assertEqual(status, 0)
self.assertEqual(output, ["line1", "line2", "line3"])
def test_return_status_and_command(self):
status, cmd = self.exe.exec_command_live(
"echo test",
source_erplibre=False,
quiet=True,
return_status_and_command=True,
)
self.assertEqual(status, 0)
self.assertEqual(cmd, "echo test")
def test_return_status_and_output_and_command(self):
status, cmd, output = self.exe.exec_command_live(
"echo result",
source_erplibre=False,
quiet=True,
return_status_and_output_and_command=True,
)
self.assertEqual(status, 0)
self.assertEqual(cmd, "echo result")
self.assertEqual(output, ["result"])
def test_source_erplibre_prepends_activate(self):
status, cmd = self.exe.exec_command_live(
"echo test",
source_erplibre=True,
quiet=True,
return_status_and_command=True,
)
self.assertIn(".venv.erplibre/bin/activate", cmd)
def test_single_source_erplibre(self):
status, cmd = self.exe.exec_command_live(
"echo test",
source_erplibre=False,
single_source_erplibre=True,
quiet=True,
return_status_and_command=True,
)
self.assertIn(".venv.erplibre/bin/activate", cmd)
self.assertIn("echo test", cmd)
def test_single_source_odoo_no_version_returns_error(self):
with patch("os.path.exists", return_value=False):
result = self.exe.exec_command_live(
"echo test",
source_erplibre=False,
single_source_odoo=True,
source_odoo="",
quiet=True,
)
# `1`, pas `-1` : e24b185 a rendu à ce chemin la FORME que
# l'appelant demande (un tuple s'il en attend un) et en a profité
# pour donner un vrai code de sortie. Aucun appelant ne compare à
# -1, qui n'est d'ailleurs pas un code de sortie valide.
self.assertEqual(result, 1)
def test_single_source_odoo_with_version(self):
status, cmd = self.exe.exec_command_live(
"echo test",
source_erplibre=False,
single_source_odoo=True,
source_odoo="odoo18",
quiet=True,
return_status_and_command=True,
)
self.assertIn(".venv.odoo18/bin/activate", cmd)
def test_new_env_passed_to_subprocess(self):
status, output = self.exe.exec_command_live(
"echo $MY_TEST_VAR",
source_erplibre=False,
quiet=True,
new_env={"MY_TEST_VAR": "test_value_123"},
return_status_and_output=True,
)
self.assertEqual(status, 0)
self.assertEqual(output, ["test_value_123"])
def test_empty_output_command(self):
status, output = self.exe.exec_command_live(
"true",
source_erplibre=False,
quiet=True,
return_status_and_output=True,
)
self.assertEqual(status, 0)
self.assertEqual(output, [])
[FIX] execute : caviarder les clés d'API et les jetons Bearer Le filtre ne connaissait que trois noms de variable — mot de passe, secret, jeton — donc `OPENAI_API_KEY=` partait en clair dans le terminal, dans les journaux et dans toute sortie CI qui les capture. Un jeton d'en-tête échappait aux deux règles par construction : il ne porte ni nom d'option ni nom de variable, il suit le mot « Bearer ». Le filtre couvre maintenant `API_KEY` côté variables et `Authorization: Bearer|Basic` côté en-têtes, sans casse, la valeur allant jusqu'au premier blanc. Il protège au même titre la restauration de base, qui l'appelle sur six sorties. Reste le dernier rempart et non le premier : argv est lisible par tout compte de la machine, où aucun caviardage n'atteint. --- EN --- The filter knew only three variable names — password, secret, token — so `OPENAI_API_KEY=` went out in the clear to the terminal, to the logs and to any CI output capturing them. A header token escaped both rules by construction: it carries neither an option name nor a variable name, it follows the word "Bearer". The filter now covers `API_KEY` on the variable side and `Authorization: Bearer|Basic` on the header side, case-insensitively, the value running to the first blank. It protects database restore just as much, which calls it on six outputs. It stays the last line of defence, not the first: argv is readable by every account on the machine, where no redaction reaches. Assisted-by: Claude Opus 5
2026-09-09 04:29:46 -04:00
class TestRedactSecrets(unittest.TestCase):
"""Ce qui doit disparaître d'une commande affichée, et ce qui doit rester.
Le caviardage est le DERNIER rempart et non le premier : un secret sur
argv est déjà lisible par tout compte de la machine dans
/proc/<pid>/cmdline, où aucun filtre n'atteint. Ces tests défendent donc
la trace — terminal, journal, sortie CI — et rien d'autre.
Trois familles portent un secret, et elles ne se ressemblent pas. Une
option se reconnaît par son nom, une variable d'environnement par le
sien, et un jeton d'en-tête n'a NI l'un NI l'autre : il suit le mot
« Bearer ». Un filtre bâti sur les deux premières laisse passer la
troisième, qui est exactement celle qu'une API de modèle emploie.
Les valeurs sont inventées, comme l'exige la règle du dépôt pour tout
exemple qui illustre un interdit.
"""
def test_env_api_key_is_redacted(self):
sortie = redact_secrets("OPENAI_API_KEY=sk-inventeXYZ python x.py")
self.assertNotIn("sk-inventeXYZ", sortie)
self.assertIn("OPENAI_API_KEY='***'", sortie)
def test_env_apikey_without_separator(self):
sortie = redact_secrets("MISTRAL_APIKEY=abc123 ./run")
self.assertNotIn("abc123", sortie)
def test_bearer_header_is_redacted(self):
sortie = redact_secrets(
"curl -H 'Authorization: Bearer sk-inventeABC' http://h/v1/models"
)
self.assertNotIn("sk-inventeABC", sortie)
self.assertIn("Authorization: Bearer '***'", sortie)
def test_basic_header_is_redacted(self):
sortie = redact_secrets("Authorization: Basic dXNlcjpmYXV4")
self.assertNotIn("dXNlcjpmYXV4", sortie)
def test_header_case_is_ignored(self):
sortie = redact_secrets("authorization: bearer sk-inventeDEF")
self.assertNotIn("sk-inventeDEF", sortie)
def test_option_password_still_redacted(self):
sortie = redact_secrets("odoo --db_password 'inventeGHI'")
self.assertNotIn("inventeGHI", sortie)
def test_option_name_survives(self):
"""Le nom de l'option reste : la commande doit rester reproductible."""
sortie = redact_secrets("odoo --db_password 'inventeJKL'")
self.assertIn("--db_password", sortie)
def test_a_path_is_not_a_secret(self):
"""Rien ne disparaît d'une commande qui ne porte aucun secret."""
commande = "make test_unit_file F=test/test_execute.py"
self.assertEqual(redact_secrets(commande), commande)
def test_empty_text_survives(self):
self.assertEqual(redact_secrets(""), "")
self.assertIsNone(redact_secrets(None))
if __name__ == "__main__":
unittest.main()