erplibre/test/test_check_instance_state.py

222 lines
8.8 KiB
Python
Raw Normal View History

[ADD] analyse: l'état d'une instance, lu pour l'usage qu'on en fait Le même chiffre veut dire deux choses opposées. Zéro cron actif est le succès attendu d'une copie et une panne totale sur une production. Un rapport qui ignore cela crie au loup sur ce qu'on vient de demander, et l'on cesse de le lire. L'attente est donc déclarée, copy ou live, et chaque contrôle dit ce qu'il juge sous l'une et sous l'autre. Deux contrôles ont été ÉCARTÉS sous copy après mesure : sur la base 12 d'origine, jamais démarrée, 11 crons étaient déjà en retard et db_backup déjà vide — notre propre update_prod_to_dev les efface. Les afficher en rouge aurait été du bruit ; en vert, un mensonge. Ils sont montrés non jugés, avec la raison. Le code Python en base a été mesuré et abandonné : 132 actions serveur, zéro citant un modèle inexistant, et les 3 « modèles sans table » sont ir.autovacuum et deux autres modèles abstraits d'Odoo. --- EN --- The same number means two opposite things. Zero active cron is the expected success of a copy and a total outage on production. A report that ignores this cries wolf over what was just requested, and stops being read. The expectation is therefore declared, copy or live, and each check states what it judges under either. Two checks were DROPPED under copy after measuring: on the untouched 12 source database, 11 crons were already late and db_backup already empty — our own update_prod_to_dev deletes them. Red would have been noise; green a lie. They are shown unjudged, with the reason. In-database Python was measured and dropped: 132 server actions, none naming a missing model, and the 3 "models without a table" are ir.autovacuum and two other Odoo abstract models. Assisted-by: Claude Opus 5
2026-08-24 14:39:09 -04:00
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La polarité : le même chiffre, deux lectures opposées.
Zéro cron actif est le succès attendu d'une copie de développement et une
panne totale sur une production. C'est la propriété centrale de cet outil,
et c'est aussi celle qu'une refonte casserait sans bruit : il suffirait
qu'un contrôle perde une de ses deux politiques pour qu'il se taise —
sans erreur, sans rouge, en donnant l'impression d'avoir été vérifié.
Le second sujet de ce fichier est le secret. Une clé de paiement VIVANTE
a été mesurée dans une base de test. Un rapport finit dans un billet ou
devant un agent : aucune requête ne doit lire la valeur d'un secret, et
c'est vérifié sur le texte des requêtes, pas sur l'intention.
"""
import os
import sys
import unittest
sys.path.insert(
0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.analyse import check_instance_state as etat # noqa: E402
class TestEveryCheckDeclaresBothReadings(unittest.TestCase):
"""Un contrôle sans politique se tairait — sans erreur ni rouge."""
def test_each_check_has_a_policy_for_each_expectation(self):
for controle in etat.CONTROLES:
for attente in etat.ATTENTES:
self.assertIn(attente, controle, controle["key"])
self.assertTrue(controle[attente], controle["key"])
def test_each_policy_is_one_we_know_how_to_read(self):
connus = ("zero", "nonzero", "info", "skip")
for controle in etat.CONTROLES:
for attente in etat.ATTENTES:
self.assertIn(controle[attente][0], connus, controle["key"])
def test_a_judging_policy_carries_a_gravity_we_understand(self):
for controle in etat.CONTROLES:
for attente in etat.ATTENTES:
politique = controle[attente]
if politique[0] in ("zero", "nonzero"):
self.assertIn(politique[1], ("broken", "watch"))
def test_a_judging_policy_explains_itself(self):
"""Un rouge sans raison n'apprend rien à qui le lit."""
for controle in etat.CONTROLES:
for attente in etat.ATTENTES:
if controle[attente][0] in ("zero", "nonzero"):
self.assertTrue(
controle.get(f"why_{attente}", "").strip(),
f"{controle['key']}/{attente}",
)
def test_a_skipped_check_says_why(self):
for controle in etat.CONTROLES:
for attente in etat.ATTENTES:
politique = controle[attente]
if politique[0] == "skip":
self.assertGreater(len(politique), 1, controle["key"])
self.assertTrue(politique[1].strip())
def test_every_section_declared_is_actually_used(self):
utilisees = {c["section"] for c in etat.CONTROLES}
self.assertEqual(set(etat.SECTIONS), utilisees)
def test_keys_are_unique(self):
cles = [c["key"] for c in etat.CONTROLES]
self.assertEqual(len(cles), len(set(cles)))
class TestThePolarityActuallyInverts(unittest.TestCase):
"""La propriété centrale, épinglée sur un cas réel mesuré."""
def _controle(self, cle):
for controle in etat.CONTROLES:
if controle["key"] == cle:
return controle
raise AssertionError(cle)
def test_active_crons_are_a_fault_on_a_copy_and_normal_when_live(self):
controle = self._controle("cron_active")
copie = etat.verdict(controle, 35, etat.COPY)
vivante = etat.verdict(controle, 35, etat.LIVE)
self.assertEqual(copie[0], "bad")
self.assertEqual(vivante[0], "ok")
def test_no_cron_at_all_is_the_opposite(self):
controle = self._controle("cron_active")
self.assertEqual(etat.verdict(controle, 0, etat.COPY)[0], "ok")
self.assertEqual(etat.verdict(controle, 0, etat.LIVE)[0], "bad")
def test_late_jobs_are_not_judged_on_a_copy(self):
"""Mesuré : 11 en retard sur la base d'ORIGINE, jamais démarrée."""
controle = self._controle("cron_late")
genre, _, raison = etat.verdict(controle, 11, etat.COPY)
self.assertEqual(genre, "skip")
self.assertTrue(raison)
self.assertEqual(etat.verdict(controle, 11, etat.LIVE)[0], "bad")
def test_missing_backups_are_not_judged_on_a_copy(self):
"""update_prod_to_dev les efface : leur absence ne prouve rien."""
controle = self._controle("backup_rows")
self.assertEqual(etat.verdict(controle, 0, etat.COPY)[0], "skip")
self.assertEqual(etat.verdict(controle, 0, etat.LIVE)[0], "bad")
def test_having_no_mail_server_warns_under_both_readings(self):
"""Zéro serveur NE prouve PAS la sûreté : Odoo retombe sur la conf.
C'est pourquoi le `neutralize.sql` d'Odoo INSÈRE un serveur bouchon
`invalid:1025` au lieu de tout supprimer, avec le commentaire
« prevent using fallback servers ». Notre `disable_mail_server`, lui,
fait `unlink()` — il rouvre la porte qu'Odoo ferme.
"""
controle = self._controle("mail_server_total")
for attente in etat.ATTENTES:
self.assertEqual(etat.verdict(controle, 0, attente)[0], "bad")
def test_a_check_without_a_policy_is_skipped_never_approved(self):
"""L'oubli doit se voir comme un trou, pas comme un feu vert."""
orphelin = {"key": "x", "section": "Users", "title": "x", "sql": ""}
for attente in etat.ATTENTES:
self.assertEqual(etat.verdict(orphelin, 99, attente)[0], "skip")
def test_an_unreadable_check_is_not_a_zero(self):
controle = self._controle("cron_active")
genre, _, raison = etat.verdict(
controle, {"error": "relation absente"}, etat.COPY
)
self.assertEqual(genre, "unreadable")
self.assertIn("relation", raison)
class TestWhatTheReportSaysAndCounts(unittest.TestCase):
def _tout(self, valeur=0):
return {c["key"]: valeur for c in etat.CONTROLES}
def test_the_header_names_the_reading(self):
for attente, mot in (
(etat.COPY, "a development copy"),
(etat.LIVE, "a live instance"),
):
texte = etat.render("b", self._tout(), attente, colour=False)
self.assertIn(etat.t(mot), texte)
def test_only_faults_reach_the_exit_code(self):
resultats = self._tout()
resultats["cron_active"] = 35 # faute sous copy, normal sous live
self.assertTrue(etat.findings(resultats, etat.COPY))
self.assertFalse(
[
f
for f in etat.findings(resultats, etat.LIVE)
if f[0]["key"] == "cron_active"
]
)
def test_a_skipped_check_shows_its_number_and_its_reason(self):
resultats = self._tout()
resultats["cron_late"] = 11
texte = etat.render("b", resultats, etat.COPY, colour=False)
self.assertIn("11", texte)
self.assertIn(
etat.t(
"Nobody runs the scheduler on a restored copy:"
" measured 11 late on an untouched source"
" database."
),
texte,
)
def test_an_unreadable_check_is_shown_as_unknown(self):
resultats = self._tout()
resultats["mail_stuck"] = {"error": "relation absente"}
texte = etat.render("b", resultats, etat.LIVE, colour=False)
self.assertIn("❔", texte)
def test_sections_come_out_in_the_declared_order(self):
texte = etat.render("b", self._tout(), etat.COPY, colour=False)
positions = [texte.find(etat.t(s)) for s in etat.SECTIONS]
self.assertEqual(positions, sorted(positions))
self.assertNotIn(-1, positions)
class TestNoQueryEverReadsASecret(unittest.TestCase):
"""Une clé Stripe VIVANTE a été mesurée dans une base de test."""
SECRETS = (
"secret_key",
"publishable_key",
"smtp_pass",
"password",
"sftp_password",
"database.secret",
"api_key",
)
def test_no_secret_column_is_selected(self):
for controle in etat.CONTROLES:
minuscule = controle["sql"].lower()
for secret in self.SECRETS:
self.assertNotIn(secret, minuscule, controle["key"])
def test_every_query_reads_and_returns_one_number(self):
for controle in etat.CONTROLES:
sql = " ".join(controle["sql"].split())
self.assertTrue(sql.upper().startswith("SELECT"), controle["key"])
majuscule = f" {sql.upper()} "
for interdit in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER"):
self.assertNotIn(f" {interdit} ", majuscule, controle["key"])
if __name__ == "__main__":
unittest.main()