#!/usr/bin/env python3 """Le panneau « Intrants de base » ecrit SANS effacer la memoire du fichier. Ce que ce test garde, et pourquoi il existe. Les fichiers d'intrants portent, a cote de leurs valeurs, l'explication des decisions : pourquoi ce resolveur d'amorcage, pourquoi ce plan de gestion, pourquoi cette interface de frontiere. Ces phrases sont la seule trace de raisonnements qu'aucun code ne redit — et elles etaient effacees a chaque enregistrement du panneau, qui repassait le fichier entier au `safe_dump` (mesure du 2026-08-18 : 121 lignes d'explications emportees en un clic, dans quatre fichiers). Le meme fichier avait deja perdu des CLEFS de la meme facon, le 2026-08-10, avant que la fusion ne soit posee. Deux fois le meme geste destructeur, sur le meme chemin : il lui fallait un test. Ce qui est verifie ici est donc le contrat d'ecriture, pas l'apparence du YAML : une valeur qui change, une liste qui change, une clef qu'on n'a pas touchee, une clef que le panneau ne connait pas, une clef nouvelle — et l'idempotence. """ import sys import tempfile from pathlib import Path RACINE = Path(__file__).resolve().parent.parent.parent sys.path.insert(0, str(RACINE / "scripts")) import yaml # noqa: E402 import inventory_gui as gui # noqa: E402 ENTETE = "# Intrants de test.\n---\n" # Un fichier realiste : commentaires d'entete, commentaire attache a une clef, # commentaire INTERNE a une liste, et une clef que le panneau ne gere pas. SOURCE = """# Intrants d'IDENTITE de l'instance — SOURCE UNIQUE. --- domaine_interne: chezlepro.internal # Resolveur d'AMORCAGE, pose par cloud-init. Il ne sert qu'une fois : `client_unbound` # bascule ensuite /etc/resolv.conf vers 127.0.0.1. Sans lui, la VM nait sans resolution. dns_amorcage: 9.9.9.9,149.112.112.112 fuseau_horaire: America/Toronto nftables_admin_ssh: # Le VLAN 10 EST le reseau d'administration (D-54). L'ancienne valeur 192.168.255.0/24 # decrivait le monde d'avant. - 10.0.0.0/24 - 192.168.254.2/32 # Adresse du compte d'amorcage — la SEULE valeur qui ne peut pas se deriver. amorcage_acces_courriel: sysadmin@chezlepro.ca cle_inconnue_du_panneau: gardee """ def _ecrire(source: str, valeurs: dict) -> str: with tempfile.TemporaryDirectory() as tmp: p = Path(tmp) / "10-intrants.yml" p.write_text(source, encoding="utf-8") fusion = gui._lire_yaml_dict(p) fusion.update(valeurs) gui._ecrire_intrants_fichier(p, fusion, ENTETE) return p.read_text(encoding="utf-8") def test_un_commentaire_survit_au_changement_de_la_valeur_qu_il_explique(): sortie = _ecrire(SOURCE, {"dns_amorcage": "1.1.1.1"}) assert "dns_amorcage: 1.1.1.1" in sortie assert "bascule ensuite /etc/resolv.conf vers 127.0.0.1" in sortie assert "Resolveur d'AMORCAGE" in sortie def test_une_liste_multi_lignes_est_remplacee_et_ses_commentaires_restent(): sortie = _ecrire(SOURCE, {"nftables_admin_ssh": ["10.17.0.0/24"]}) data = yaml.safe_load(sortie) assert data["nftables_admin_ssh"] == ["10.17.0.0/24"] assert "192.168.254.2/32" not in sortie # l'ancienne valeur est bien partie assert "Le VLAN 10 EST le reseau d'administration (D-54)" in sortie def test_une_clef_inchangee_n_est_pas_reecrite(): sortie = _ecrire(SOURCE, {"dns_amorcage": "1.1.1.1"}) # Ni retriee, ni requotee, ni deplacee : la ligne est celle d'origine. assert "fuseau_horaire: America/Toronto" in sortie assert sortie.index("domaine_interne") < sortie.index("dns_amorcage") assert sortie.splitlines()[0].startswith("# Intrants d'IDENTITE") def test_une_clef_que_le_panneau_ignore_survit_avec_son_commentaire(): sortie = _ecrire(SOURCE, {"fuseau_horaire": "America/Montreal"}) data = yaml.safe_load(sortie) assert data["cle_inconnue_du_panneau"] == "gardee" assert data["amorcage_acces_courriel"] == "sysadmin@chezlepro.ca" assert "la SEULE valeur qui ne peut pas se deriver" in sortie def test_une_clef_nouvelle_est_ajoutee_a_la_fin(): sortie = _ecrire(SOURCE, {"organisation": "Chezlepro"}) data = yaml.safe_load(sortie) assert data["organisation"] == "Chezlepro" assert data["domaine_interne"] == "chezlepro.internal" assert "Resolveur d'AMORCAGE" in sortie def test_deux_enregistrements_identiques_ne_changent_rien(): une = _ecrire(SOURCE, {"dns_amorcage": "1.1.1.1"}) deux = _ecrire(une, {"dns_amorcage": "1.1.1.1"}) assert une == deux def test_toutes_les_valeurs_restent_lisibles_en_yaml(): sortie = _ecrire(SOURCE, {"nftables_admin_ssh": ["10.17.0.0/24"], "dns_amorcage": "1.1.1.1"}) data = yaml.safe_load(sortie) assert data == { "domaine_interne": "chezlepro.internal", "dns_amorcage": "1.1.1.1", "fuseau_horaire": "America/Toronto", "nftables_admin_ssh": ["10.17.0.0/24"], "amorcage_acces_courriel": "sysadmin@chezlepro.ca", "cle_inconnue_du_panneau": "gardee", } def test_le_garde_fou_des_cles_sensibles_tient_toujours(): interdite = sorted(gui.INTRANTS_CLES_INTERDITES)[0] try: _ecrire(SOURCE, {interdite: "valeur-en-clair"}) except ValueError as e: assert "sensibles" in str(e) else: raise AssertionError(f"une valeur en clair sur '{interdite}' aurait du etre refusee") def test_un_fichier_absent_est_cree_avec_son_entete(): with tempfile.TemporaryDirectory() as tmp: p = Path(tmp) / "neuf" / "10-intrants.yml" gui._ecrire_intrants_fichier(p, {"domaine_interne": "exemple.internal"}, ENTETE) texte = p.read_text(encoding="utf-8") assert texte.startswith("# Intrants de test.") assert yaml.safe_load(texte) == {"domaine_interne": "exemple.internal"} if __name__ == "__main__": tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] for t in tests: t() print(f"OK — {len(tests)} test(s) intrants du GUI : les commentaires survivent a l'ecriture.")