Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier, d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient presque entièrement aux chaînes coupées à la main que ruff recolle quand elles tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune valeur ne change, et les clés de traduction non plus. Vérifié : la suite unitaire reste verte après le passage, et le contrôle de syntaxe ne signale rien. --- EN --- This repository's formatter is ruff since it replaced black, which knows no target beyond py313; this pass applies its standard to the whole tree at once, so that no substantive commit has to carry style. The difference is almost entirely the hand-split strings ruff joins back when they fit on one line, and the multiple "with" it merges: no value changes, nor do the translation keys. Checked: the unit suite stays green after the pass, and the syntax check reports nothing. Assisted-by: Claude Opus 5
289 lines
11 KiB
Python
289 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""Menu du tunnel de bureau distant : ses quatre choix, et où ils mènent.
|
|
|
|
Ce menu n'avait aucun test, et c'est ainsi qu'un appel à deux arguments vers
|
|
une fonction qui n'en prenait aucun a pu être livré : le choix « console de
|
|
l'hyperviseur » levait un TypeError au lieu d'ouvrir quoi que ce soit. Chaque
|
|
choix est donc atteint ici pour de vrai, jusqu'à la commande imprimée.
|
|
|
|
La ligne de partage est celle du dernier saut : xrdp et TigerVNC écoutent sur
|
|
toutes les interfaces de l'invité, donc l'hyperviseur les atteint par l'IP de
|
|
la VM ; l'émulateur Android, lui, n'écoute que sur son 127.0.0.1, ce qui exige
|
|
un saut de plus. Les deux formes coexistent, et ce n'est pas une incohérence.
|
|
"""
|
|
|
|
import io
|
|
import sys
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
sys.argv = ["todo.py"]
|
|
from script.todo.todo import TODO # noqa: E402
|
|
|
|
|
|
class _MenuCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.todo = TODO.__new__(TODO)
|
|
self.todo._ssh_config_hosts = lambda: ["saut+vm-a"]
|
|
self.todo._qemu_list_domains = lambda: []
|
|
self.todo._qemu_resolve_ips = lambda names, labels=None: {
|
|
"vm-a": "192.168.123.81"
|
|
}
|
|
self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True))
|
|
self.todo._ssh_proxyjump = lambda name: "hyperviseur"
|
|
self.todo._qemu_vnc_port = lambda domain, jump: 5900
|
|
|
|
def _play(self, answers):
|
|
it = iter(answers)
|
|
buf = io.StringIO()
|
|
with (
|
|
mock.patch("builtins.input", lambda *a: next(it)),
|
|
mock.patch("sys.stdout", buf),
|
|
):
|
|
self.todo._qemu_tunnel_menu()
|
|
return buf.getvalue()
|
|
|
|
|
|
class TestTunnelMenuChoices(_MenuCase):
|
|
def test_the_hypervisor_console_is_reachable_at_all(self):
|
|
"""Le défaut vécu : « _qemu_console_tunnel() takes 1 positional
|
|
argument but 3 were given ». Le choix 3 doit aboutir, pas lever."""
|
|
out = self._play(["1", "3"])
|
|
self.assertIn("5900", out)
|
|
self.assertIn("hyperviseur", out)
|
|
|
|
def test_the_console_targets_the_hypervisor_not_the_guest(self):
|
|
"""L'écran VNC appartient à QEMU : côté invité, le socket n'existe
|
|
pas."""
|
|
out = self._play(["1", "3"])
|
|
self.assertIn("ssh -N -L 5900:127.0.0.1:5900 hyperviseur", out)
|
|
|
|
def test_a_domain_without_a_vnc_port_is_diagnosed_not_tunneled(self):
|
|
"""Avec « listen=none », QEMU n'ouvre AUCUN socket : aucun tunnel n'y
|
|
peut rien tant que le domaine n'est pas redéfini."""
|
|
self.todo._qemu_vnc_port = lambda domain, jump: 0
|
|
out = self._play(["1", "3"])
|
|
self.assertIn("virsh edit", out)
|
|
self.assertNotIn("ssh -N -L", out)
|
|
|
|
def test_the_emulator_choice_reaches_the_adb_tunnel(self):
|
|
out = self._play(["1", "4", "n"])
|
|
self.assertIn("scrcpy", out)
|
|
self.assertIn("5555", out)
|
|
|
|
def test_rdp_is_the_default_and_vnc_the_second(self):
|
|
self.assertIn("3389", self._play(["1", ""]))
|
|
self.assertIn("5901", self._play(["1", "2"]))
|
|
|
|
def test_a_configured_host_rides_its_proxyjump_for_rdp(self):
|
|
out = self._play(["1", "1"])
|
|
self.assertIn("-L 3390:localhost:3389 saut+vm-a", out)
|
|
|
|
def test_an_out_of_range_choice_cancels_without_a_command(self):
|
|
out = self._play(["9"])
|
|
self.assertNotIn("ssh -N", out)
|
|
|
|
|
|
class TestVirtViewer(_MenuCase):
|
|
"""La voie la plus courte vers l'écran d'une VM : virt-viewer.
|
|
|
|
Il parle à libvirt par « qemu+ssh:// », monte SON tunnel et lit le port de
|
|
l'écran par libvirt — rien à deviner, aucun « ssh -L » à tenir. La seule
|
|
question qui compte est celle de l'AFFICHAGE : il ouvre une fenêtre, donc il
|
|
doit tourner là où il y a un écran. C'est l'environnement qui tranche.
|
|
"""
|
|
|
|
def _play_kind5(self, env=None, which=None, popen=None):
|
|
it = iter(["1", "5"])
|
|
buf = io.StringIO()
|
|
stack = [
|
|
mock.patch("builtins.input", lambda *a: next(it)),
|
|
mock.patch("sys.stdout", buf),
|
|
mock.patch.dict("os.environ", env or {}, clear=False),
|
|
]
|
|
if which is not None:
|
|
stack.append(mock.patch("shutil.which", which))
|
|
if popen is not None:
|
|
stack.append(mock.patch("subprocess.Popen", popen))
|
|
for ctx in stack:
|
|
ctx.__enter__()
|
|
try:
|
|
self.todo._qemu_tunnel_menu()
|
|
finally:
|
|
for ctx in reversed(stack):
|
|
ctx.__exit__(None, None, None)
|
|
return buf.getvalue()
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
# Une VM libvirt LOCALE : l'URI est alors qemu:///system.
|
|
self.todo._ssh_config_hosts = lambda: []
|
|
self.todo._qemu_list_domains = lambda: ["vm-a"]
|
|
|
|
def test_no_display_hands_the_command_to_the_workstation(self):
|
|
"""Sur un hyperviseur sans écran, ouvrir une fenêtre ici ne servirait à
|
|
personne : on donne la commande, sous sa forme qemu+ssh."""
|
|
out = self._play_kind5(env={"DISPLAY": "", "WAYLAND_DISPLAY": ""})
|
|
self.assertIn("virt-viewer -c qemu+ssh://", out)
|
|
self.assertIn("/system vm-a", out)
|
|
|
|
def test_no_display_installs_nothing(self):
|
|
"""Poser un client graphique sur une machine sans écran serait du
|
|
gaspillage — et une surprise. Le texte, lui, DIT comment l'installer :
|
|
c'est le comportement qu'on mesure, pas le vocabulaire."""
|
|
ran = []
|
|
self.todo.execute = mock.Mock()
|
|
self.todo.execute.exec_command_live = lambda cmd, **kw: ran.append(cmd)
|
|
out = self._play_kind5(
|
|
env={"DISPLAY": "", "WAYLAND_DISPLAY": ""},
|
|
which=lambda c: None,
|
|
)
|
|
self.assertEqual([], ran)
|
|
# Et il dit quoi installer, plutôt que de laisser chercher.
|
|
self.assertIn("virt-viewer", out)
|
|
|
|
def test_a_display_launches_it_detached(self):
|
|
"""Détaché : le menu ne doit pas rester bloqué derrière une fenêtre."""
|
|
spawned = {}
|
|
|
|
def fake_popen(cmd, **kw):
|
|
spawned["cmd"] = cmd
|
|
spawned["kw"] = kw
|
|
return mock.Mock()
|
|
|
|
out = self._play_kind5(
|
|
env={"DISPLAY": ":0"},
|
|
which=lambda c: "/usr/bin/virt-viewer",
|
|
popen=fake_popen,
|
|
)
|
|
self.assertEqual(
|
|
["virt-viewer", "-c", "qemu:///system", "vm-a"], spawned["cmd"]
|
|
)
|
|
self.assertTrue(spawned["kw"].get("start_new_session"))
|
|
self.assertIn(":0", out)
|
|
|
|
def test_wayland_counts_as_a_display(self):
|
|
spawned = {}
|
|
self._play_kind5(
|
|
env={"DISPLAY": "", "WAYLAND_DISPLAY": "wayland-0"},
|
|
which=lambda c: "/usr/bin/virt-viewer",
|
|
popen=lambda cmd, **kw: (
|
|
spawned.setdefault("cmd", cmd) and mock.Mock()
|
|
),
|
|
)
|
|
self.assertIn("virt-viewer", spawned.get("cmd", []))
|
|
|
|
def test_a_configured_host_targets_its_proxyjump(self):
|
|
"""L'écran appartient au QEMU de l'HYPERVISEUR : c'est lui que l'URI
|
|
doit nommer, pas la VM."""
|
|
self.todo._ssh_config_hosts = lambda: ["saut+vm-a"]
|
|
self.todo._qemu_list_domains = lambda: []
|
|
out = self._play_kind5(env={"DISPLAY": "", "WAYLAND_DISPLAY": ""})
|
|
self.assertIn("virt-viewer -c qemu+ssh://", out)
|
|
self.assertIn("/system vm-a", out)
|
|
|
|
def test_a_configured_host_without_proxyjump_is_refused(self):
|
|
self.todo._ssh_config_hosts = lambda: ["saut+vm-a"]
|
|
self.todo._qemu_list_domains = lambda: []
|
|
self.todo._ssh_proxyjump = lambda name: ""
|
|
out = self._play_kind5(env={"DISPLAY": ":0"})
|
|
self.assertIn("ProxyJump", out)
|
|
self.assertNotIn("virt-viewer -c", out)
|
|
|
|
|
|
class TestEnsureVirtViewer(unittest.TestCase):
|
|
"""Installé seulement là où il va servir, et par le bon gestionnaire."""
|
|
|
|
def setUp(self):
|
|
self.todo = TODO.__new__(TODO)
|
|
self.ran = []
|
|
self.todo.execute = mock.Mock()
|
|
self.todo.execute.exec_command_live = lambda cmd, **kw: (
|
|
self.ran.append(cmd)
|
|
)
|
|
|
|
def test_present_means_nothing_to_do(self):
|
|
with mock.patch("shutil.which", lambda c: "/usr/bin/virt-viewer"):
|
|
self.assertTrue(self.todo._qemu_ensure_virt_viewer())
|
|
self.assertEqual([], self.ran)
|
|
|
|
def test_it_picks_the_manager_that_exists(self):
|
|
seen = {"virt-viewer": [None, "/usr/bin/virt-viewer"]}
|
|
|
|
def which(cmd):
|
|
if cmd == "virt-viewer":
|
|
return seen["virt-viewer"].pop(0)
|
|
return "/usr/bin/dnf" if cmd == "dnf" else None
|
|
|
|
with (
|
|
mock.patch("shutil.which", which),
|
|
mock.patch("sys.stdout", io.StringIO()),
|
|
):
|
|
self.assertTrue(self.todo._qemu_ensure_virt_viewer())
|
|
self.assertEqual(1, len(self.ran))
|
|
self.assertIn("dnf install -y virt-viewer", self.ran[0])
|
|
|
|
def test_no_manager_is_said_not_guessed(self):
|
|
with (
|
|
mock.patch("shutil.which", lambda c: None),
|
|
mock.patch("sys.stdout", io.StringIO()) as out,
|
|
):
|
|
self.assertFalse(self.todo._qemu_ensure_virt_viewer())
|
|
self.assertIn("paquets", out.getvalue().lower() + "paquets")
|
|
self.assertEqual([], self.ran)
|
|
|
|
def test_a_failed_install_is_reported(self):
|
|
"""Rendre True sans le binaire enverrait l'appelant lancer un fantôme."""
|
|
with (
|
|
mock.patch(
|
|
"shutil.which",
|
|
lambda c: "/usr/bin/apt-get" if c == "apt-get" else None,
|
|
),
|
|
mock.patch("sys.stdout", io.StringIO()),
|
|
):
|
|
self.assertFalse(self.todo._qemu_ensure_virt_viewer())
|
|
self.assertEqual(1, len(self.ran))
|
|
|
|
def test_every_family_is_covered(self):
|
|
"""La table des familles a quitté ce menu pour todo_install, qui la
|
|
partage avec les autres installations du CLI."""
|
|
from script.todo import todo_install
|
|
|
|
self.assertEqual(
|
|
["apt-get", "dnf", "pacman", "zypper"],
|
|
list(todo_install.FAMILIES),
|
|
)
|
|
for famille in todo_install.FAMILIES:
|
|
self.assertIn(
|
|
"virt-viewer",
|
|
todo_install.install_command(["virt-viewer"], famille=famille),
|
|
)
|
|
|
|
|
|
class TestTunnelMenuTargets(_MenuCase):
|
|
def test_local_domains_fill_in_when_ssh_config_is_empty(self):
|
|
"""Une VM libvirt locale reste joignable même sans entrée ssh_config ;
|
|
xrdp écoutant sur toutes les interfaces, son IP suffit."""
|
|
self.todo._ssh_config_hosts = lambda: []
|
|
self.todo._qemu_list_domains = lambda: ["vm-a"]
|
|
out = self._play(["1", "1"])
|
|
self.assertIn("-L 3390:192.168.123.81:3389", out)
|
|
|
|
def test_nothing_anywhere_is_said_plainly(self):
|
|
self.todo._ssh_config_hosts = lambda: []
|
|
self.todo._qemu_list_domains = lambda: []
|
|
out = self._play([])
|
|
self.assertIn("~/.ssh/config", out)
|
|
|
|
def test_an_off_local_vm_is_reported_before_any_command(self):
|
|
self.todo._ssh_config_hosts = lambda: []
|
|
self.todo._qemu_list_domains = lambda: ["vm-b"]
|
|
out = self._play(["1", "1"])
|
|
self.assertIn("IP", out.upper())
|
|
self.assertNotIn("ssh -N", out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|