Set-OPS-Public/scripts/tests/test_ecriture_plan.py
Daniel Allaire 0156596f01 P02 : une table du plan ne se coupe plus a son premier commentaire
_fusion_table traversait mal les commentaires interieurs a une entree :
chezlepro.ca (DNSSEC) etait coupee et ses enregistrements pris pour des entrees.
make verifier : CONFORME, 83 OK, 0 echec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:17:52 -04:00

186 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""Sauvegarder depuis le GUI ne doit emporter aucun commentaire du plan.
CE QUI ETAIT VRAI JUSQU'AU 2026-09-08, mesure sur les fichiers reels de Chezlepro :
domaines.yml 6 lignes de commentaire -> 3 (PERD 3)
applications.yml 27 -> 5 (PERD 22)
serveurs.yml 18 -> 3 (PERD 15)
bases-donnees.yml 4 -> 4 (intact — il n'en portait pas)
Quarante lignes, detruites par n'importe quel clic sur « Sauvegarder » dans les vues
Serveurs, Applications ou Domaines. Parmi elles, celle qui explique pourquoi `backup-01`
a ete retire — « une supervision creuse est pire qu'aucune : elle est verte » — et celle
qui dit dans quel ORDRE les deux roles du runner s'appliquent.
C'etait l'incident du 2026-08-18 (94 lignes perdues dans les fichiers d'intrants), jamais
corrige pour les registres du plan : `_fusion_chirurgicale` avait ete ecrite pour les
intrants seuls, et les quatre `ecrire_*` etaient restes au `safe_dump`.
Ce banc mesure les quatre ecrivains sur les VRAIS fichiers du depot, et non sur un
gabarit invente : c'est la seule facon de constater ce que l'operateur perdrait.
"""
from __future__ import annotations
import re
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(RACINE / "scripts"))
import yaml # noqa: E402
import inventory_gui as g # noqa: E402
# (nom, fichier source, ecrivain, chargeur, clef racine)
REGISTRES = [
("domaines.yml", g.FICHIER_DOMAINES, g.ecrire_domaines, g.charger_domaines, "domaines_publics"),
("bases-donnees.yml", g.FICHIER_BASES, g.ecrire_bases, g.charger_bases_donnees, "bases_donnees"),
("applications.yml", g.FICHIER_APPLICATIONS, g.ecrire_applications, g.charger_applications, "applications"),
("serveurs.yml", g.FICHIER_SERVEURS, g.ecrire_serveurs, g.charger_serveurs, "serveurs"),
]
def commentaires(texte: str) -> list[str]:
return [l.strip() for l in texte.splitlines() if l.strip().startswith("#")]
def commentaires_hors_entree(texte: str, cle: str) -> list[str]:
"""Les commentaires du fichier, MOINS ceux qui vivent DANS l'entree `cle`.
Un commentaire interieur a une entree (2026-09-16 : `chezlepro.ca` explique sa
signature DNSSEC entre `secondaires:` et `dnssec:`) part avec elle : laisse en place,
il expliquerait l'entree voisine. Ceux qui l'ENTOURENT restent. Meme regle d'etendue
que `_fusion_table` : un commentaire suivi d'une ligne encore plus indentee est dedans.
"""
lignes = texte.splitlines()
debut = next((i for i, l in enumerate(lignes)
if re.match(rf"^(\s+){re.escape(cle)}:", l)), None)
if debut is None:
return commentaires(texte)
indent = len(lignes[debut]) - len(lignes[debut].lstrip())
dedans, j = set(), debut + 1
while j < len(lignes):
l = lignes[j]
if not l.strip() or l.strip().startswith("#"):
k = j
while k < len(lignes) and (not lignes[k].strip() or lignes[k].strip().startswith("#")):
k += 1
if k < len(lignes) and len(lignes[k]) - len(lignes[k].lstrip()) > indent:
dedans.update(range(j, k))
j = k
continue
break
if len(l) - len(l.lstrip()) <= indent:
break
j += 1
return [l.strip() for i, l in enumerate(lignes)
if l.strip().startswith("#") and i not in dedans]
class TestEcriturePlan(unittest.TestCase):
def setUp(self):
self.dossier = Path(tempfile.mkdtemp(prefix="plan-"))
def tearDown(self):
shutil.rmtree(self.dossier, ignore_errors=True)
def _copie(self, nom, source):
cible = self.dossier / nom
shutil.copy(source, cible)
return cible
def test_aller_retour_a_vide_est_identique(self):
"""Ecrire ce qu'on vient de lire ne doit pas changer un octet.
C'est la propriete forte : sans elle, toute sauvegarde produit du bruit au diff,
et le bruit finit par cacher le vrai changement.
"""
for nom, source, ecrire, charger, _ in REGISTRES:
with self.subTest(registre=nom):
cible = self._copie(nom, source)
avant = cible.read_text(encoding="utf-8")
ecrire(cible, charger(source))
self.assertEqual(avant, cible.read_text(encoding="utf-8"))
def test_aucun_commentaire_perdu_apres_modification(self):
for nom, source, ecrire, charger, racine in REGISTRES:
with self.subTest(registre=nom):
cible = self._copie(nom, source)
avant = cible.read_text(encoding="utf-8")
registre = charger(source)
registre[racine]["essai-banc"] = dict(
next(iter(registre[racine].values()), {}) or {})
ecrire(cible, registre)
self.assertEqual(commentaires(avant), commentaires(cible.read_text(encoding="utf-8")))
def test_une_entite_ajoutee_arrive_bien(self):
for nom, source, ecrire, charger, racine in REGISTRES:
with self.subTest(registre=nom):
cible = self._copie(nom, source)
registre = charger(source)
modele = dict(next(iter(registre[racine].values()), {}) or {})
registre[racine]["essai-banc"] = modele
ecrire(cible, registre)
relu = yaml.safe_load(cible.read_text(encoding="utf-8"))
self.assertIn("essai-banc", relu[racine])
self.assertEqual(relu[racine]["essai-banc"], modele)
def test_une_entite_retiree_disparait(self):
for nom, source, ecrire, charger, racine in REGISTRES:
with self.subTest(registre=nom):
cible = self._copie(nom, source)
avant = cible.read_text(encoding="utf-8")
registre = charger(source)
if len(registre[racine]) < 2:
continue
victime = sorted(registre[racine])[0]
del registre[racine][victime]
ecrire(cible, registre)
apres = cible.read_text(encoding="utf-8")
relu = yaml.safe_load(apres)
self.assertNotIn(victime, relu[racine])
# Les autres entites restent, et les commentaires aussi : retirer une
# entite n'est pas une raison d'effacer ce qui l'entourait.
self.assertEqual(sorted(relu[racine]), sorted(registre[racine]))
# Ce qui ENTOURAIT l'entite reste ; ce qui etait DEDANS part avec elle.
self.assertEqual(commentaires_hors_entree(avant, victime), commentaires(apres))
def test_controle_negatif_safe_dump_detruit(self):
"""LE CONTROLE. L'ancienne ecriture DOIT perdre des commentaires.
Sans lui, les tests ci-dessus passeraient sur un fichier qui n'en porte aucun —
ils ne mesureraient rien. On rejoue le `safe_dump` d'avant sur les memes fichiers
et on exige la perte.
"""
perdants = []
for nom, source, _, charger, racine in REGISTRES:
avant = source.read_text(encoding="utf-8")
naif = "---\n" + yaml.safe_dump({racine: charger(source)[racine]},
default_flow_style=False, sort_keys=False,
allow_unicode=True)
if len(commentaires(naif)) < len(commentaires(avant)):
perdants.append(nom)
self.assertIn("serveurs.yml", perdants,
"le controle negatif ne reproduit plus le defaut : serveurs.yml "
"ne porte-t-il plus de commentaire interne ?")
self.assertGreaterEqual(len(perdants), 3, f"seuls {perdants} perdent — banc suspect")
def test_un_fichier_absent_recoit_son_entete(self):
"""Rien a preserver : on ecrit l'entete et le contenu."""
cible = self.dossier / "neuf.yml"
g.ecrire_domaines(cible, {"domaines_publics": {"exemple.ca": {"autorite": "delegue",
"edge": "serveur_nginx"}}})
texte = cible.read_text(encoding="utf-8")
self.assertIn("# Registre des domaines publics", texte)
self.assertIn("exemple.ca", yaml.safe_load(texte)["domaines_publics"])
if __name__ == "__main__":
unittest.main()