148 lines
5.8 KiB
Python
148 lines
5.8 KiB
Python
|
|
#!/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.")
|