Six tests asserted that an assistant module does not import script.todo.todo by looking at sys.modules of the running process. Under the full suite another test had already imported todo, so all six failed whatever the module did. Each now imports its module in a fresh interpreter and reads the answer there. Checked: the six pass after test_todo_menu, the order that broke them, and a module made to import todo fails its test. --- FR --- Six tests affirmaient qu'un module de l'assistant n'importe pas script.todo.todo en regardant le sys.modules du processus en cours. Dans la suite complète, un autre test avait déjà importé todo : les six échouaient quoi que fasse le module. Chacun importe désormais son module dans un interpréteur neuf et y lit la réponse. Vérifié : les six passent après test_todo_menu, l'ordre qui les faisait tomber, et un module qu'on fait importer todo fait échouer son test. Assisted-by: Claude Opus 5
392 lines
16 KiB
Python
392 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""Le balayage : ce qu'on planifie, ce qu'on frappe, et rien d'autre.
|
|
|
|
Trois décisions sont défendues ici, et chacune casse en silence.
|
|
|
|
**Le préfixe se lit, il ne se devine pas.** Une machine porte volontiers deux
|
|
/24 — un sur son interface physique, un sur un pont de virtualisation où elle
|
|
est la passerelle — et « le /24 local » n'a alors pas de sens. Une régression
|
|
qui choisirait pour l'utilisateur balaierait le mauvais réseau sans rien
|
|
lever, et un réseau plus large qu'un /24 doit être REFUSÉ plutôt que
|
|
rétréci : rétrécir présenterait une hypothèse comme une lecture.
|
|
|
|
**Le balayage ne touche que ce qu'on lui a désigné.** Les adresses de la
|
|
machine elle-même sont écartées du plan, sans quoi le balayage se reconnaît
|
|
lui-même et offre la passerelle d'un pont comme un serveur découvert. Et la
|
|
table de voisinage ne rend que ce qui a RÉPONDU : une entrée sans adresse
|
|
matérielle dit qu'on a demandé sans obtenir.
|
|
|
|
**Le battement de cœur est testable, et c'est le point.** L'idiome de
|
|
progression du dépôt code son intervalle en dur, et c'est exactement pourquoi
|
|
cette branche-là n'a aucun test. Ici l'intervalle et l'horloge sont injectés.
|
|
|
|
Aucun paquet n'est émis : le connecteur est injecté partout, `ip` est
|
|
remplacé par du texte, et le cas du /24 complet remplace en plus
|
|
`socket.create_connection` par une levée, pour qu'un chemin oublié tombe en
|
|
rouge au lieu de parler au réseau.
|
|
|
|
Les adresses sont celles des blocs réservés à la documentation, et les noms
|
|
d'interface sont inventés — `.claude/rules/04-code-conventions.md` exige que
|
|
la valeur illustrant un interdit s'invente, et « lien0 » comme « pont0 » ne
|
|
paraissent nulle part ailleurs dans le dépôt.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
sys.path.append(
|
|
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
|
)
|
|
|
|
from script.todo.assistant import discover # noqa: E402
|
|
from script.todo.assistant import fingerprint # noqa: E402
|
|
|
|
# Deux réseaux des blocs réservés à la documentation : ils ne peuvent
|
|
# désigner aucune machine réelle.
|
|
LAN = "192.0.2.0/24"
|
|
PONT = "198.51.100.0/24"
|
|
|
|
# Ce que « ip -o -4 addr show » annonce : la boucle locale, une interface
|
|
# ordinaire, un pont. La forme « -o » met l'interface et son préfixe sur la
|
|
# même ligne, ce qui est ce qui les rend rattachables.
|
|
ADDR = (
|
|
"1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever\n"
|
|
"2: lien0 inet 192.0.2.10/24 metric 1024 brd 192.0.2.255 scope"
|
|
" global dynamic lien0\\ valid_lft 2968sec\n"
|
|
"3: pont0 inet 198.51.100.1/24 brd 198.51.100.255 scope global"
|
|
" pont0\\ valid_lft forever\n"
|
|
)
|
|
|
|
# Les mêmes interfaces, annoncées dans l'ordre inverse : l'ordre des rangs ne
|
|
# doit pas décider laquelle ouvre la liste.
|
|
ADDR_INVERSE = "\n".join(
|
|
[ADDR.splitlines()[0], ADDR.splitlines()[2], ADDR.splitlines()[1]]
|
|
)
|
|
|
|
# Ce que « ip -d -o link show » annonce. « bridge » nu est le TYPE, et lui
|
|
# seul nomme un pont ; « bridge_slave » est l'attribut que porte un PORT de
|
|
# pont, et « bridge_id » un attribut du pont lui-même. Les trois paraissent
|
|
# ici pour que la frontière de mot du motif soit ce qui les sépare.
|
|
LINK = (
|
|
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN"
|
|
"\\ link/loopback 00:00:00:00:00:00 addrgenmode eui64\n"
|
|
"2: lien0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP"
|
|
"\\ link/ether aa:bb:cc:dd:ee:01 promiscuity 0"
|
|
" bridge_slave state forwarding\n"
|
|
"3: pont0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 state DOWN"
|
|
"\\ link/ether aa:bb:cc:dd:ee:02 promiscuity 0"
|
|
"\\ bridge forward_delay 200 bridge_id 8000.aa:bb:cc:dd:ee:02\n"
|
|
)
|
|
|
|
# La route par défaut désigne l'interface qui sort de la machine. Le noyau a
|
|
# déjà tranché, et c'est cette réponse-là qui ouvre la liste.
|
|
ROUTE = (
|
|
"default via 192.0.2.1 dev lien0 proto dhcp src 192.0.2.10 metric 1024\n"
|
|
)
|
|
|
|
# Une table de voisinage. Les deux premières ont répondu — elles portent une
|
|
# adresse matérielle ; les deux suivantes ont été sollicitées sans répondre ;
|
|
# la dernière répète la première sur une autre interface.
|
|
NEIGH = (
|
|
"192.0.2.11 dev lien0 lladdr aa:bb:cc:dd:ee:11 REACHABLE\n"
|
|
"192.0.2.12 dev lien0 lladdr aa:bb:cc:dd:ee:12 STALE\n"
|
|
"192.0.2.13 dev lien0 FAILED\n"
|
|
"192.0.2.14 dev lien0 INCOMPLETE\n"
|
|
"192.0.2.11 dev pont0 lladdr aa:bb:cc:dd:ee:11 STALE\n"
|
|
)
|
|
|
|
|
|
def faux_ip(addr=ADDR, link=LINK, route=ROUTE):
|
|
"""Un exécuteur de « ip » qui rend du texte choisi, sans sous-processus.
|
|
|
|
Se branche sur `local_networks(run=…)` et distingue les trois questions
|
|
par leur verbe, comme la commande elle-même.
|
|
"""
|
|
|
|
def run(argv):
|
|
if "addr" in argv:
|
|
return addr
|
|
if "link" in argv:
|
|
return link
|
|
if "route" in argv:
|
|
return route
|
|
return ""
|
|
|
|
return run
|
|
|
|
|
|
class Horloge:
|
|
"""Une horloge injectée qui avance d'une seconde par lecture.
|
|
|
|
Une durée rendue par elle vaut au moins une seconde, là où le balayage
|
|
d'un test dure quelques centièmes : une durée supérieure à la seconde
|
|
PROUVE donc que c'est bien elle qui a été lue, et non l'horloge du
|
|
système.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.lectures = 0
|
|
|
|
def __call__(self):
|
|
self.lectures += 1
|
|
return 1000.0 + self.lectures
|
|
|
|
|
|
class Compteur:
|
|
"""Un connecteur injecté : rend vrai sur les couples ouverts, et compte
|
|
ses appels."""
|
|
|
|
def __init__(self, ouverts):
|
|
self.ouverts = set(ouverts)
|
|
self.appels = 0
|
|
|
|
def __call__(self, host, port, timeout):
|
|
self.appels += 1
|
|
return (host, port) in self.ouverts
|
|
|
|
|
|
class LesReseauxLocaux(unittest.TestCase):
|
|
def test_les_deux_slash24_locaux_sont_offerts(self):
|
|
reseaux = discover.local_networks(run=faux_ip())
|
|
self.assertTrue(reseaux, "aucun réseau lu : le motif ne colle plus")
|
|
self.assertEqual([LAN, PONT], [item.cidr for item in reseaux])
|
|
self.assertEqual(["lien0", "pont0"], [i.name for i in reseaux])
|
|
|
|
def test_la_boucle_locale_n_est_pas_un_reseau_a_balayer(self):
|
|
"""Elle se sonde en quelques millisecondes, sans balayage : l'offrir
|
|
ferait payer 254 adresses pour une seule."""
|
|
reseaux = discover.local_networks(run=faux_ip())
|
|
self.assertTrue(reseaux, "aucun réseau lu")
|
|
for item in reseaux:
|
|
self.assertNotIn("127.", item.cidr)
|
|
self.assertNotEqual("lo", item.name)
|
|
|
|
def test_le_pont_libvirt_est_signale_comme_tel(self):
|
|
"""Le type « bridge » nomme un pont ; les attributs « bridge_slave »
|
|
et « bridge_id » n'en nomment pas un, et la ligne de l'interface
|
|
ordinaire porte le premier pour le prouver."""
|
|
par_nom = {i.name: i for i in discover.local_networks(run=faux_ip())}
|
|
self.assertTrue(par_nom, "aucune interface lue")
|
|
self.assertTrue(par_nom["pont0"].is_bridge)
|
|
self.assertFalse(par_nom["lien0"].is_bridge)
|
|
|
|
def test_l_interface_de_la_route_par_defaut_ouvre_la_liste(self):
|
|
reseaux = discover.local_networks(
|
|
run=faux_ip(addr=ADDR_INVERSE, route=ROUTE)
|
|
)
|
|
self.assertTrue(reseaux, "aucun réseau lu")
|
|
self.assertEqual("lien0", reseaux[0].name)
|
|
self.assertEqual({"lien0", "pont0"}, {i.name for i in reseaux})
|
|
|
|
def test_un_prefixe_plus_large_est_rendu_tel_quel_pas_retreci(self):
|
|
"""Rétrécir en silence présenterait une hypothèse comme une lecture :
|
|
le refus appartient au planificateur, qui peut le DIRE."""
|
|
large = "2: lien0 inet 192.0.2.10/16 scope global lien0\n"
|
|
reseaux = discover.local_networks(run=faux_ip(addr=large))
|
|
self.assertEqual(["192.0.0.0/16"], [i.cidr for i in reseaux])
|
|
self.assertEqual([], discover.plan_sweep(reseaux[0].cidr))
|
|
|
|
def test_un_ip_absent_est_une_liste_vide_pas_une_panne(self):
|
|
def leve(argv):
|
|
raise FileNotFoundError(argv[0])
|
|
|
|
self.assertEqual([], discover.local_networks(run=leve))
|
|
self.assertEqual([], discover.local_networks(run=lambda argv: ""))
|
|
|
|
|
|
class LePlanDeBalayage(unittest.TestCase):
|
|
def test_plus_large_qu_un_slash24_est_refuse(self):
|
|
for cidr in ("192.0.2.0/23", "192.0.2.0/16", "10.0.0.0/8", "::/64"):
|
|
self.assertEqual([], discover.plan_sweep(cidr), cidr)
|
|
self.assertEqual(254, discover.MAX_HOSTS)
|
|
|
|
def test_un_slash24_donne_tous_ses_hotes_fois_tous_les_ports(self):
|
|
jobs = discover.plan_sweep(LAN)
|
|
attendu = discover.MAX_HOSTS * len(fingerprint.PORTS)
|
|
self.assertEqual(attendu, len(jobs))
|
|
self.assertEqual(("192.0.2.1", fingerprint.PORTS[0]), jobs[0])
|
|
|
|
def test_les_ports_par_defaut_sont_ceux_de_l_echelle(self):
|
|
jobs = discover.plan_sweep("192.0.2.7/32")
|
|
self.assertEqual(
|
|
[(str("192.0.2.7"), port) for port in fingerprint.PORTS], jobs
|
|
)
|
|
|
|
def test_les_adresses_de_l_hote_sont_ecartees(self):
|
|
"""Sans ce retrait, un balayage se reconnaît lui-même et offre la
|
|
passerelle d'un pont comme un serveur découvert."""
|
|
siennes = ("192.0.2.10", "192.0.2.1")
|
|
jobs = discover.plan_sweep(LAN, (11434,), skip=siennes)
|
|
self.assertTrue(jobs, "plan vide : le retrait a tout emporté")
|
|
self.assertEqual(discover.MAX_HOSTS - 2, len(jobs))
|
|
for adresse in siennes:
|
|
self.assertNotIn((adresse, 11434), jobs)
|
|
|
|
def test_un_cidr_illisible_est_une_liste_vide(self):
|
|
for cidre in ("", " ", "pas-un-cidr", "192.0.2.0/33", None, 42):
|
|
self.assertEqual([], discover.plan_sweep(cidre), repr(cidre))
|
|
|
|
def test_un_ensemble_de_ports_vide_ne_planifie_rien(self):
|
|
self.assertEqual([], discover.plan_sweep(LAN, ()))
|
|
|
|
|
|
class LaTableDeVoisinage(unittest.TestCase):
|
|
def test_la_table_de_voisinage_ne_rend_que_ce_qui_a_parle(self):
|
|
adresses = discover.neigh_hosts(NEIGH)
|
|
self.assertEqual(["192.0.2.11", "192.0.2.12"], adresses)
|
|
|
|
def test_une_table_vide_ou_abimee_ne_leve_pas(self):
|
|
for texte in ("", None, "n'importe quoi\nlladdr sans adresse\n"):
|
|
self.assertEqual([], discover.neigh_hosts(texte), repr(texte))
|
|
|
|
|
|
class LaFrappe(unittest.TestCase):
|
|
def test_un_slash24_complet_n_emet_aucun_paquet(self):
|
|
"""Le connecteur injecté est le seul chemin : une socket ouverte par
|
|
un chemin oublié tombe en rouge au lieu de parler au réseau."""
|
|
jobs = discover.plan_sweep(LAN)
|
|
ouverts = {("192.0.2.7", 11434), ("192.0.2.42", 8080)}
|
|
connect = Compteur(ouverts)
|
|
with patch(
|
|
"script.todo.assistant.discover.socket.create_connection",
|
|
side_effect=AssertionError("aucun paquet ne doit sortir"),
|
|
):
|
|
trouves = discover.sweep(jobs, connect=connect)
|
|
self.assertEqual(len(jobs), connect.appels)
|
|
self.assertEqual(ouverts, set(trouves))
|
|
|
|
def test_le_battement_part_sur_l_horloge_injectee(self):
|
|
horloge = Horloge()
|
|
evenements = []
|
|
|
|
def lent(host, port, timeout):
|
|
time.sleep(0.06)
|
|
return False
|
|
|
|
discover.sweep(
|
|
[("192.0.2.7", 11434)],
|
|
connect=lent,
|
|
heartbeat_sec=0.01,
|
|
now=horloge,
|
|
on_event=evenements.append,
|
|
)
|
|
battements = [e for e in evenements if e[0] == "heartbeat"]
|
|
self.assertTrue(battements, "aucun battement : la branche est muette")
|
|
for _nom, faits, total, secondes in battements:
|
|
self.assertLessEqual(faits, total)
|
|
self.assertGreaterEqual(secondes, 1.0)
|
|
|
|
def test_un_hote_bloque_n_arrete_pas_les_autres(self):
|
|
bloque = ("192.0.2.99", 11434)
|
|
ouverts = {("192.0.2.7", 11434), ("192.0.2.8", 1234)}
|
|
|
|
def connect(host, port, timeout):
|
|
if (host, port) == bloque:
|
|
time.sleep(0.05)
|
|
return False
|
|
return (host, port) in ouverts
|
|
|
|
jobs = sorted(ouverts | {bloque, ("192.0.2.9", 5001)})
|
|
trouves = discover.sweep(
|
|
jobs, connect=connect, heartbeat_sec=0.01, timeout=0.01
|
|
)
|
|
self.assertEqual(ouverts, set(trouves))
|
|
|
|
def test_un_connecteur_qui_leve_compte_pour_un_port_ferme(self):
|
|
def connect(host, port, timeout):
|
|
if host == "192.0.2.99":
|
|
raise OSError("pile réseau à bout")
|
|
return port == 11434
|
|
|
|
jobs = [("192.0.2.99", 11434), ("192.0.2.7", 11434)]
|
|
self.assertEqual(
|
|
[("192.0.2.7", 11434)], discover.sweep(jobs, connect=connect)
|
|
)
|
|
|
|
def test_les_evenements_rendent_les_memes_couples_que_le_retour(self):
|
|
"""L'appelant peut affirmer sur des DONNÉES et non sur du texte
|
|
capté : c'est ce qui rend l'affichage remplaçable."""
|
|
jobs = discover.plan_sweep("192.0.2.0/24", (11434, 1234))
|
|
ouverts = {("192.0.2.7", 11434), ("192.0.2.9", 1234)}
|
|
evenements = []
|
|
trouves = discover.sweep(
|
|
jobs,
|
|
connect=Compteur(ouverts),
|
|
on_event=evenements.append,
|
|
)
|
|
dits = [(e[1], e[2]) for e in evenements if e[0] == "hit"]
|
|
self.assertTrue(dits, "aucune trouvaille annoncée")
|
|
self.assertEqual(sorted(trouves), sorted(dits))
|
|
self.assertEqual(
|
|
[("done", len(trouves), len(jobs))],
|
|
[e[:3] for e in evenements if e[0] == "done"],
|
|
)
|
|
|
|
def test_un_plan_vide_se_termine_sans_piscine(self):
|
|
evenements = []
|
|
self.assertEqual([], discover.sweep([], on_event=evenements.append))
|
|
self.assertEqual(["done"], [e[0] for e in evenements])
|
|
|
|
|
|
class LesSourcesInjectees(unittest.TestCase):
|
|
def test_la_piscine_est_dimensionnee_par_vagues_pas_par_coeurs(self):
|
|
"""Ces fils attendent le réseau : un compte de cœurs à deux chiffres
|
|
multiplierait par quinze la durée d'un /24."""
|
|
source = _source()
|
|
self.assertIn("min(len(jobs), workers)", source)
|
|
self.assertNotIn("cpu_count", source)
|
|
|
|
def test_la_sonde_est_une_connexion_jamais_un_sous_processus_ping(self):
|
|
"""Un `ping` par adresse coûterait un millier de fork+exec sur un
|
|
/24, là où une socket n'en coûte aucun."""
|
|
source = _source()
|
|
self.assertIn("socket.create_connection", source)
|
|
self.assertNotIn('"ping"', source)
|
|
|
|
|
|
class LaFrontiere(unittest.TestCase):
|
|
def test_le_balayage_ne_tire_pas_todo(self):
|
|
"""Importer `script.todo.todo` coûte près d'une seconde et imprime
|
|
sur la sortie : le paquet doit rester importable seul."""
|
|
# Dans un interpréteur NEUF : la suite complète importe todo par
|
|
# ailleurs, et le sys.modules de ce processus en garderait la trace
|
|
# quel que soit le module éprouvé ici.
|
|
sortie = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"import sys, script.todo.assistant.discover, script.todo.assistant.fingerprint;"
|
|
" print('script.todo.todo' in sys.modules)",
|
|
],
|
|
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
self.assertEqual(sortie.returncode, 0, sortie.stderr)
|
|
self.assertEqual(sortie.stdout.strip(), "False")
|
|
|
|
def test_aucun_asyncio_du_depot_n_est_utilise(self):
|
|
"""`AsyncioPool` enveloppe des sous-processus et non des sockets, et
|
|
passe à `asyncio.wait` un argument retiré de Python 3.10."""
|
|
self.assertNotIn("lib_asyncio", _source())
|
|
|
|
|
|
def _source():
|
|
"""Le texte du module de découverte, non vide."""
|
|
with open(discover.__file__, encoding="utf-8") as fh:
|
|
texte = fh.read()
|
|
if not re.search(r"def sweep\(", texte):
|
|
raise AssertionError("source illisible : le module a changé de forme")
|
|
return texte
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|