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()