From 5411b102a9a9ecf489e5f226e908508d8e16a398 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 24 Sep 2026 13:32:28 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20d=C3=A9ploiement=20:=20un=20proxy=20SOC?= =?UTF-8?q?KS=20par=20SSH,=20et=20son=20mode=20d'emploi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « -L » relaie UN service ; « -D » ouvre un relais SOCKS, par lequel le navigateur atteint n'importe quelle destination depuis la machine distante — une interface qui n'écoute que sur sa boucle locale, un hôte de son réseau sans route depuis ici. Le port par défaut est 1080, modifiable, et contrôlé libre avant d'ouvrir. La saisie de l'adresse est celle de sshfs, désormais partagée : l'alias de ~/.ssh/config part tel quel à ssh, faute de quoi son ProxyJump se perdrait et une VM imbriquée deviendrait injoignable. Le réglage de Firefox s'affiche AVANT le lancement, la commande ne rendant la main qu'au Ctrl+C. Vérifié : 9 tests neufs, et la numérotation du menu que deux autres gardent. --- EN --- "-L" relays ONE service; "-D" opens a SOCKS relay, through which the browser reaches any destination from the remote machine — an interface listening only on its loopback, a host of its network with no route from here. The default port is 1080, changeable, and checked free before opening. The address prompt is sshfs's, now shared: the ~/.ssh/config alias goes to ssh as is, failing which its ProxyJump would be lost and a nested VM become unreachable. The Firefox settings print BEFORE the launch, the command only returning on Ctrl+C. Checked: 9 new tests, and the menu numbering two others guard. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 169 ++++++++++++++++++++++------------ script/todo/todo_i18n.py | 75 +++++++++++---- test/test_proxmox_deploy.py | 4 +- test/test_qemu_cache_menu.py | 26 +++--- test/test_todo_socks_proxy.py | 129 ++++++++++++++++++++++++++ 5 files changed, 316 insertions(+), 87 deletions(-) create mode 100644 test/test_todo_socks_proxy.py diff --git a/script/todo/todo.py b/script/todo/todo.py index a9a872c..884cd03 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -139,7 +139,6 @@ from script.todo.version_manager import get_odoo_version from script.todo.vpn_menu import VpnMenuMixin ERROR_LOG_PATH = ".erplibre.error.txt" -VENV_ERPLIBRE = ".venv.erplibre" ENABLE_CRASH = False CRASH_E = None # Support mobile ERPLibre @@ -480,13 +479,11 @@ class TODO( ) # TODO detect last version supported # cmd_intern = "./script/install/install_erplibre.sh" - # TODO maybe update q to only install erplibre from install_locally - # TODO problem installing with q, the script depend on odoo key_i = 0 commands_begin = { "q": ( "q", - "q: ERPLibre only with system python without Odoo", + "q: ERPLibre only without Odoo, with the required Python", "./script/install/install_erplibre.sh", ), "w": ( @@ -1057,6 +1054,7 @@ class TODO( "SSH port forwarding (open Odoo in the browser)" ) }, + {"prompt_description": t("Configure a SOCKS proxy over SSH")}, {"section": t("Remote & services")}, {"prompt_description": t("SSH (remote host)...")}, { @@ -1100,16 +1098,18 @@ class TODO( elif status == "3": self._deploy_port_forward() elif status == "4": - self.prompt_execute_deploy_ssh() + self._deploy_socks_proxy() elif status == "5": - self.prompt_execute_qemu() + self.prompt_execute_deploy_ssh() elif status == "6": - self.prompt_execute_proxmox() + self.prompt_execute_qemu() elif status == "7": - self._deploy_ntfy_server() + self.prompt_execute_proxmox() elif status == "8": - self.prompt_execute_qemu_cache() + self._deploy_ntfy_server() elif status == "9": + self.prompt_execute_qemu_cache() + elif status == "10": self.prompt_execute_vpn() else: print(t("Command not found !")) @@ -1648,8 +1648,7 @@ class TODO( # l'agent au lieu de les remplacer, et le serveur coupe après # 5 essais infructueux. block += ( - f" IdentityFile {identity_file}\n" - f" IdentitiesOnly yes\n" + f" IdentityFile {identity_file}\n IdentitiesOnly yes\n" ) if proxy_jump: block += f" ProxyJump {proxy_jump}\n" @@ -1810,15 +1809,11 @@ class TODO( print(f"{t('Directory already exists: ')}{target_path}") return print(t("Cloning ERPLibre...")) - cmd = ( - "git clone" - " https://github.com/erplibre/erplibre" - f" {target_path}" - ) + cmd = f"git clone https://github.com/erplibre/erplibre {target_path}" print(f"{t('Will execute:')} {cmd}") try: self.execute.exec_command_live(cmd, source_erplibre=False) - print(f"{t('ERPLibre cloned successfully to: ')}" f"{target_path}") + print(f"{t('ERPLibre cloned successfully to: ')}{target_path}") except Exception as e: print(f"{t('Error cloning ERPLibre: ')}{e}") @@ -2385,27 +2380,32 @@ class TODO( ) print(f" → {t('Update ~/.ssh/config, or check the server is up.')}") - def _configure_sshfs(self): + def _ask_ssh_target(self): + """Demande OÙ se connecter, à la main ou depuis ~/.ssh/config. + + Rend (cible, utilisateur, hôte, nom, depuis_config), ou None si l'on + renonce. La CIBLE est ce qu'on passe à ssh : l'alias quand il vient du + fichier de configuration, pour que son User et son ProxyJump + s'appliquent — un « user@hôte » écrit à la main les perdrait, et une VM + imbriquée sans route directe deviendrait injoignable. + + DEPUIS_CONFIG distingue les deux, que le nom seul ne sépare pas : + l'appelant n'interroge ~/.ssh/config que pour une adresse qui en vient. + """ import getpass - import re - from datetime import datetime print(f"\n{t('SSH address input method')}") print(f"[1] {t('Manual entry')}") print(f"[2] {t('From ~/.ssh/config')}") choice = input(t("Your choice (1/2): ")).strip() - user = None - hostname = None - ssh_name = None - if choice == "2": ssh_config_path = os.path.expanduser("~/.ssh/config") hosts = self._ssh_config_entries(ssh_config_path) if not hosts: print(t("No SSH hosts found in ~/.ssh/config")) - return + return None print() for i, (host, info) in enumerate(hosts, 1): @@ -2417,36 +2417,98 @@ class TODO( if u: desc += f" [{u}]" print(f"[{i}] {desc}") - sel = input(t("Select SSH host number: ")).strip() try: idx = int(sel) - 1 if idx < 0 or idx >= len(hosts): print(t("Invalid selection!")) - return + return None except ValueError: print(t("Invalid selection!")) - return + return None host_name, host_info = hosts[idx] hostname = host_info.get("hostname", host_name) user = host_info.get("user", getpass.getuser()) - ssh_name = host_name - target = f"{host_name}:/" + return host_name, user, hostname, host_name, True + + ssh_host = input(t("SSH host (e.g.: user@192.168.1.100): ")).strip() + if not ssh_host: + print(t("SSH host is required!")) + return None + if "@" in ssh_host: + user, hostname = ssh_host.split("@", 1) else: - ssh_host = input( - t("SSH host (e.g.: user@192.168.1.100): ") - ).strip() - if not ssh_host: - print(t("SSH host is required!")) + hostname = ssh_host + user = getpass.getuser() + return f"{user}@{hostname}", user, hostname, hostname, False + + def _deploy_socks_proxy(self): + """Ouvre un proxy SOCKS qui fait sortir le navigateur PAR la machine + distante. + + « -D » n'ouvre pas un tunnel vers UN service, comme « -L », mais un + relais SOCKS : le navigateur y envoie n'importe quelle destination, et + c'est la machine distante qui l'atteint. De quoi lire une interface + qui n'écoute que sur sa boucle locale, ou joindre un hôte de son + réseau sans route depuis ici. + + « -N » n'ouvre aucun shell — rien à exécuter là-bas —, et « -C » + comprime, ce qui se sent sur une liaison lente. + """ + print(f"\n🧦 {t('SOCKS proxy over SSH')}") + choisi = self._ask_ssh_target() + if not choisi: + return + cible = choisi[0] + + raw = input(f"{t('SOCKS port (default:')} 1080): ").strip() + port = raw if raw.isdigit() else "1080" + + if not self._port_is_free(port): + print(f" ⚠ {t('Local port already in use:')} {port}") + if not self._is_yes(input(t("Try anyway? (y/N): "))): return - if "@" in ssh_host: - user, hostname = ssh_host.split("@", 1) - else: - hostname = ssh_host - user = getpass.getuser() - ssh_name = hostname - target = f"{user}@{hostname}:/" + + cmd = f"ssh -D {port} -N -C {shlex.quote(cible)}" + print(f"\n {t('Will execute:')} {cmd}") + # Le mode d'emploi passe AVANT : la commande ne rend la main qu'au + # Ctrl+C, et c'est pendant qu'elle tourne qu'on règle le navigateur. + self._print_socks_help(port) + print(f" {t('Ctrl+C closes the tunnel.')}\n") + try: + self.execute.exec_command_live(cmd, source_erplibre=False) + except KeyboardInterrupt: + pass + print(f"\n {t('Tunnel closed.')}") + + @staticmethod + def _print_socks_help(port): + """Le réglage du navigateur, qu'aucune commande ne fait à sa place.""" + # Les deux libellés qui portent des guillemets sortent de la + # f-string : les y laisser en réutiliserait le délimiteur, ce que + # Python n'accepte qu'à partir de 3.12. + choix = t('then choose "Manual proxy configuration":') + dns = t('Tick "Proxy DNS when using SOCKS v5"') + print(f"\n ── {t('Firefox configuration')} ──") + print(f" {t('Settings, then Network Settings and Settings...,')}") + print(f" {choix}\n") + print(f" {t('SOCKS host:')} 127.0.0.1, {t('port')} {port}") + print(f" {t('Tick SOCKS v5')}") + print(f" {dns}") + print(f"\n {t('Domain names are then resolved on the remote side,')}") + print(f" {t('which reaches internal names such as localhost, or')}") + print(f" {t('hosts of the remote network.')}\n") + + def _configure_sshfs(self): + import re + from datetime import datetime + + choisi = self._ask_ssh_target() + if not choisi: + return + cible, user, hostname, ssh_name, depuis_config = choisi + target = f"{cible}:/" safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", ssh_name) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -2462,7 +2524,7 @@ class TODO( # et lui qu'on peut interroger en cas d'échec. Une saisie manuelle est # rendue telle quelle — si elle contient un « + », c'est un chaînage # demandé exprès. - alias = ssh_name if choice == "2" else "" + alias = ssh_name if depuis_config else "" if alias: cmd, bypassed = self._sshfs_command(alias, mount_point) else: @@ -2491,7 +2553,7 @@ class TODO( return print(f"{t('Mounted on: ')}{mount_point}") print("mount | grep sshfs") - print(f"{t('To unmount: ')}" f"fusermount -u {mount_point}") + print(f"{t('To unmount: ')}fusermount -u {mount_point}") print(f"nautilus {mount_point}/home/{user}") def _get_ssh_params(self): @@ -3204,9 +3266,7 @@ class TODO( def _deploy_git_server(self, production_ready=False, action="all"): print(t("Starting git server deployment...")) - cmd = ( - "python3 ./script/git/git_local_server.py -v" f" --action {action}" - ) + cmd = f"python3 ./script/git/git_local_server.py -v --action {action}" if production_ready: cmd += " --production-ready" self.execute.exec_command_live( @@ -3275,8 +3335,7 @@ class TODO( }, { "prompt_description": t( - "Todo Generate Code - Code by the OCA rules at high" - " effort" + "Todo Generate Code - Code by the OCA rules at high effort" ) }, {"prompt_description": t("Show installed custom commands")}, @@ -3343,7 +3402,7 @@ class TODO( name = f[:-3] # remove .md print(f" /{name:<30} {date_str}") print("-" * 50) - print(f"{t('Total:')}" f" {len(files)}") + print(f"{t('Total:')} {len(files)}") def _claude_context_root(self): """La racine du dépôt, deux niveaux au-dessus de ce fichier.""" @@ -3462,8 +3521,7 @@ class TODO( chemin_hooks = self._git_hooks_path(racine) print( - f"{t('Git hooks'):<22}" - f" {chemin_hooks or t('hook not installed')}" + f"{t('Git hooks'):<22} {chemin_hooks or t('hook not installed')}" ) if chemin_hooks: absolu = os.path.join(racine, chemin_hooks) @@ -5358,8 +5416,7 @@ class TODO( # Step 2: Install modules print(f"\n--- {t('Installing modules')}: {modules_to_install} ---") cmd_install = ( - f"./script/addons/install_addons.sh" - f" {db_name} {modules_to_install}" + f"./script/addons/install_addons.sh {db_name} {modules_to_install}" ) self.execute.exec_command_live( cmd_install, @@ -5487,9 +5544,7 @@ class TODO( env_input = "" while env_input not in environments and env_input != "0": if env_input: - print( - f"{t('Error, cannot understand value')}" f" '{env_input}'" - ) + print(f"{t('Error, cannot understand value')} '{env_input}'") env_input = input(str_input).strip() if env_input == "0": diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8116e1c..d9f1474 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -286,6 +286,55 @@ TRANSLATIONS = { "fr": "Méthode de saisie de l'adresse SSH", "en": "SSH address input method", }, + # Proxy SOCKS sur SSH (menu Déploiement › Local) + "Configure a SOCKS proxy over SSH": { + "fr": "🧦 Configurer Proxy vers SSH", + "en": "🧦 Configure a SOCKS proxy over SSH", + }, + "SOCKS proxy over SSH": { + "fr": "Proxy SOCKS par SSH", + "en": "SOCKS proxy over SSH", + }, + "SOCKS port (default:": { + "fr": "Port SOCKS (défaut :", + "en": "SOCKS port (default:", + }, + "Firefox configuration": { + "fr": "Configuration dans Firefox", + "en": "Firefox configuration", + }, + "Settings, then Network Settings and Settings...,": { + "fr": "Paramètres, puis Paramètres réseau et Paramètres…,", + "en": "Settings, then Network Settings and Settings...,", + }, + 'then choose "Manual proxy configuration":': { + "fr": "puis choisir « Configuration manuelle du proxy » :", + "en": 'then choose "Manual proxy configuration":', + }, + "SOCKS host:": { + "fr": "Hôte SOCKS :", + "en": "SOCKS host:", + }, + "Tick SOCKS v5": { + "fr": "Cocher SOCKS v5", + "en": "Tick SOCKS v5", + }, + 'Tick "Proxy DNS when using SOCKS v5"': { + "fr": "Cocher « Utiliser un DNS distant lorsque SOCKS v5 est actif »", + "en": 'Tick "Proxy DNS when using SOCKS v5"', + }, + "Domain names are then resolved on the remote side,": { + "fr": "Les noms de domaine sont alors résolus côté machine distante,", + "en": "Domain names are then resolved on the remote side,", + }, + "which reaches internal names such as localhost, or": { + "fr": "ce qui atteint des noms internes comme localhost, ou des", + "en": "which reaches internal names such as localhost, or", + }, + "hosts of the remote network.": { + "fr": "hôtes du réseau de cette machine.", + "en": "hosts of the remote network.", + }, "Manual entry": { "fr": "Saisie manuelle", "en": "Manual entry", @@ -5231,8 +5280,7 @@ TRANSLATIONS = { "fr": "Choix (numéro ou nom, vide = amd64) :", "en": "Choice (number or name, blank = amd64):", }, - "s390x is emulated (TCG): boot and install are much " - "slower than x86.": { + "s390x is emulated (TCG): boot and install are much slower than x86.": { "fr": "s390x est émulé (TCG) : le boot et l'installation sont bien " "plus lents que x86.", "en": "s390x is emulated (TCG): boot and install are much " @@ -6227,8 +6275,7 @@ TRANSLATIONS = { }, "The VM cannot boot while 3D stays in its definition.": { "fr": ( - "La VM ne démarrera pas tant que la 3D reste dans sa" - " définition." + "La VM ne démarrera pas tant que la 3D reste dans sa définition." ), "en": "The VM cannot boot while 3D stays in its definition.", }, @@ -7293,8 +7340,7 @@ TRANSLATIONS = { "fr": "cache de téléchargement : MAC de l'hôte introuvable", "en": "download cache: host MAC not found", }, - "no trust store for this distribution, its downloads " - "will fail": { + "no trust store for this distribution, its downloads will fail": { "fr": ( "pas de magasin de confiance pour cette distribution, ses " "téléchargements échoueront" @@ -11350,8 +11396,7 @@ TRANSLATIONS = { "these before anything else.", }, "Use -v to list them all, --json for the raw data.": { - "fr": "Utilisez -v pour tout afficher, --json pour la donnée " - "brute.", + "fr": "Utilisez -v pour tout afficher, --json pour la donnée brute.", "en": "Use -v to list them all, --json for the raw data.", }, "List the fields and models added outside a module — " @@ -12184,8 +12229,7 @@ TRANSLATIONS = { " (backend" ), "en": ( - "the system keyring would store the password in plaintext" - " (backend" + "the system keyring would store the password in plaintext (backend" ), }, "mail_err_keyring_plaintext_hint": { @@ -13329,12 +13373,12 @@ TRANSLATIONS = { "en": "Which technology?", }, "VPN - Create a profile from a site preset": { - "fr": "\U0001F3DB VPN - Créer un profil à partir d'un préréglage de site", - "en": "\U0001F3DB VPN - Create a profile from a site preset", + "fr": "\U0001f3db VPN - Créer un profil à partir d'un préréglage de site", + "en": "\U0001f3db VPN - Create a profile from a site preset", }, "VPN - Import an AnyConnect profile (.xml)": { - "fr": "\U0001F4E5 VPN - Importer un profil AnyConnect (.xml)", - "en": "\U0001F4E5 VPN - Import an AnyConnect profile (.xml)", + "fr": "\U0001f4e5 VPN - Importer un profil AnyConnect (.xml)", + "en": "\U0001f4e5 VPN - Import an AnyConnect profile (.xml)", }, "An AnyConnect profile usually sits in" " /opt/cisco/secureclient/vpn/profile/ (or .../anyconnect/profile/).": { @@ -14127,8 +14171,7 @@ TRANSLATIONS = { }, "Sweeping the network reaches machines you did not name.": { "fr": ( - "Balayer le réseau atteint des machines que tu n'as pas" - " nommées." + "Balayer le réseau atteint des machines que tu n'as pas nommées." ), "en": "Sweeping the network reaches machines you did not name.", }, diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index e159706..f3fe6ca 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -666,11 +666,11 @@ class TestLeMenu(unittest.TestCase): def test_the_dispatch_follows_the_list(self): src = open("script/todo/todo.py", encoding="utf-8").read() self.assertIn( - 'elif status == "6":\n self.prompt_execute_proxmox()', + 'elif status == "7":\n self.prompt_execute_proxmox()', src, ) self.assertIn( - 'elif status == "7":\n self._deploy_ntfy_server()', + 'elif status == "8":\n self._deploy_ntfy_server()', src, ) diff --git a/test/test_qemu_cache_menu.py b/test/test_qemu_cache_menu.py index 5709a08..05b0845 100644 --- a/test/test_qemu_cache_menu.py +++ b/test/test_qemu_cache_menu.py @@ -178,17 +178,17 @@ class TestEntreeDuCache(unittest.TestCase): def test_entree_dispatchee(self): self.assertRegex( self.corps, - r'elif status == "8":\s*\n\s*self\.prompt_execute_qemu_cache\(\)', + r'elif status == "9":\s*\n\s*self\.prompt_execute_qemu_cache\(\)', "l'entrée 8 ne mène pas au sous-menu du cache", ) - def test_vpn_decale_en_neuf(self): - """L'entrée insérée pousse le VPN : sans quoi deux entrées se - partagent le numéro 8 et la seconde est inatteignable.""" + def test_vpn_reste_le_dernier(self): + """Toute entrée insérée avant lui le pousse : sans quoi deux entrées + partagent un numéro, et la seconde est inatteignable.""" self.assertRegex( self.corps, - r'elif status == "9":\s*\n\s*self\.prompt_execute_vpn\(\)', - "le VPN n'a pas été décalé en 9", + r'elif status == "10":\s*\n\s*self\.prompt_execute_vpn\(\)', + "le VPN n'est plus la dernière entrée du menu", ) def test_numeros_sans_trou_ni_doublon(self): @@ -749,9 +749,10 @@ class TestLAssistantDesTests(unittest.TestCase): # « click.confirm » et « longtest_menu.click.confirm » sont le MÊME # objet : un seul mock les couvre, et c'est ce qui rend le compte # d'appels lisible — une question en tout, pas une par essai. - with mock.patch( - "click.prompt", side_effect=lambda *a, **k: next(it) - ), mock.patch("click.confirm", return_value=True) as confirme: + with ( + mock.patch("click.prompt", side_effect=lambda *a, **k: next(it)), + mock.patch("click.confirm", return_value=True) as confirme, + ): Faux()._cache_assistant() return lancees, confirme @@ -1118,9 +1119,10 @@ class TestLesReglagesDuNettoyage(unittest.TestCase): faux = menu.QemuCacheMenuMixin.__new__(menu.QemuCacheMenuMixin) faux.execute = mock.MagicMock() - with mock.patch.object( - menu.cache_offline, "reglage", return_value="" - ), contextlib.redirect_stdout(io.StringIO()): + with ( + mock.patch.object(menu.cache_offline, "reglage", return_value=""), + contextlib.redirect_stdout(io.StringIO()), + ): for a_blanc in (True, False): faux._cache_nettoyage_lancer(a_blanc=a_blanc) faux.execute.exec_command_live.assert_not_called() diff --git a/test/test_todo_socks_proxy.py b/test/test_todo_socks_proxy.py new file mode 100644 index 0000000..0650430 --- /dev/null +++ b/test/test_todo_socks_proxy.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le proxy SOCKS ouvre-t-il le bon tunnel, et le dit-il assez ? + +« -D » ne relaie pas UN service comme « -L » : il ouvre un relais SOCKS, par +lequel le navigateur atteint n'importe quelle destination depuis la machine +distante. Trois choses décident si la commande sert à quelque chose, et ces +tests les gardent : + +- l'ALIAS de ~/.ssh/config est passé tel quel à ssh. Le remplacer par + « user@hôte » perdrait son ProxyJump, et une VM imbriquée sans route directe + deviendrait injoignable ; +- le port choisi se retrouve dans la commande ET dans le mode d'emploi du + navigateur, sans quoi l'utilisateur règle Firefox sur un port qui n'écoute + pas ; +- le mode d'emploi passe AVANT le lancement : la commande ne rend la main + qu'au Ctrl+C, et c'est pendant qu'elle tourne qu'on règle le navigateur. +""" + +import builtins +import contextlib +import io +import sys +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RACINE)) + +from script.todo.todo import TODO # noqa: E402 + + +class ExecuteFactice: + def __init__(self): + self.commandes = [] + + def exec_command_live(self, cmd, **kwargs): + self.commandes.append(cmd) + + +class BancProxy(unittest.TestCase): + def joue( + self, + reponses, + cible=("vm-essai", "u", "10.0.0.1", "vm-essai", True), + ): + """Déroule la commande sur des réponses écrites d'avance.""" + todo = TODO.__new__(TODO) + todo.execute = ExecuteFactice() + todo._ask_ssh_target = lambda: cible + suite = iter(reponses) + ancien = builtins.input + builtins.input = lambda *a, **k: next(suite, "") + sortie = io.StringIO() + try: + with contextlib.redirect_stdout(sortie): + todo._deploy_socks_proxy() + finally: + builtins.input = ancien + return todo.execute.commandes, sortie.getvalue() + + +class TestLaCommande(BancProxy): + def test_le_port_par_defaut_est_1080(self): + commandes, _ = self.joue([""]) + self.assertEqual(["ssh -D 1080 -N -C vm-essai"], commandes) + + def test_le_port_se_change(self): + commandes, _ = self.joue(["9050"]) + self.assertEqual(["ssh -D 9050 -N -C vm-essai"], commandes) + + def test_un_port_qui_n_est_pas_un_nombre_retombe_sur_1080(self): + commandes, _ = self.joue(["mille-quatre-vingts"]) + self.assertEqual(["ssh -D 1080 -N -C vm-essai"], commandes) + + def test_l_alias_ssh_est_passe_tel_quel(self): + """Le remplacer par user@hôte perdrait son ProxyJump.""" + commandes, _ = self.joue( + [""], cible=("bond", "u", "10.0.0.2", "bond", True) + ) + self.assertIn(" bond", commandes[0]) + self.assertNotIn("@", commandes[0]) + + def test_renoncer_a_l_adresse_ne_lance_rien(self): + todo = TODO.__new__(TODO) + todo.execute = ExecuteFactice() + todo._ask_ssh_target = lambda: None + with contextlib.redirect_stdout(io.StringIO()): + todo._deploy_socks_proxy() + self.assertEqual([], todo.execute.commandes) + + +class TestLeModeDEmploi(BancProxy): + def test_il_nomme_le_port_choisi(self): + _, sortie = self.joue(["9050"]) + self.assertIn("127.0.0.1", sortie) + self.assertIn("9050", sortie) + self.assertNotIn("1080", sortie) + + def test_il_precede_le_lancement(self): + """La commande ne rend la main qu'au Ctrl+C.""" + _, sortie = self.joue([""]) + self.assertLess(sortie.index("127.0.0.1"), sortie.index("Ctrl+C")) + + def test_il_parle_du_dns_distant(self): + _, sortie = self.joue([""]) + self.assertIn("SOCKS v5", sortie) + self.assertIn("DNS", sortie.upper()) + + +class TestLeMenu(unittest.TestCase): + def test_l_entree_ferme_la_section_locale(self): + """Quatrième, et la suite glisse : le VPN passe de 9 à 10.""" + source = (RACINE / "script/todo/todo.py").read_text(encoding="utf-8") + debut = source.index("def prompt_execute_deploy(self)") + menu = source[debut : source.index("def prompt_execute_deploy_ssh")] + self.assertIn( + 'elif status == "4":\n self._deploy_socks_proxy()', + menu, + ) + self.assertIn( + 'elif status == "10":\n self.prompt_execute_vpn()', + menu, + ) + + +if __name__ == "__main__": + unittest.main()