Running the suite for real turned up seven errors I had just caused. The fake probe took one argument, and probe_master_password now takes two. Worse than the signature: two assertions checked that the probed command CONTAINED "--master_password=bon". They pinned exactly the exposure the previous commit removed -- a test can hold a defect in place as firmly as it holds a guarantee. They now assert the opposite, which is the property worth keeping: the password reaches the probe beside the command, and the command carries neither the value nor the option. --- FR --- Exécuter la suite pour de vrai a fait apparaître sept erreurs que je venais de causer. La fausse sonde prenait un argument, et probe_master_password en prend désormais deux. Pire que la signature : deux assertions vérifiaient que la commande sondée CONTENAIT « --master_password=bon ». Elles verrouillaient exactement l'exposition que le commit précédent a retirée — un test tient un défaut en place aussi fermement qu'une garantie. Elles affirment maintenant l'inverse, qui est la propriété à conserver : le mot de passe parvient à la sonde à CÔTÉ de la commande, et la commande ne porte ni la valeur ni l'option. Assisted-by: Claude Opus 5
181 lines
6.5 KiB
Python
181 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
"""Une faute de frappe ne doit pas coûter une migration.
|
|
|
|
Le mot de passe maître était demandé UNE fois. Faux ? Odoo lève
|
|
`AccessDenied`, `check_output` lève `CalledProcessError`, rien ne
|
|
l'attrape, et la migration meurt sur une trace. Après une heure de
|
|
paliers, c'est cher payé pour une lettre.
|
|
|
|
La propriété qui porte tout : on ne redemande QUE sur un refus de mot de
|
|
passe. Reposer la question sur n'importe quel échec cacherait la vraie
|
|
panne derrière dix invites, et l'on chercherait un mot de passe alors
|
|
que la base est cassée.
|
|
"""
|
|
|
|
import io
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from contextlib import redirect_stderr, redirect_stdout
|
|
|
|
sys.path.append(
|
|
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
|
)
|
|
|
|
from script.database import db_restore # noqa: E402
|
|
|
|
|
|
class TestRecognisingTheRefusal(unittest.TestCase):
|
|
def test_an_access_denied_is_a_refusal(self):
|
|
self.assertTrue(
|
|
db_restore.password_refused(
|
|
"Traceback...\nodoo.exceptions.AccessDenied: Access Denied"
|
|
)
|
|
)
|
|
|
|
def test_the_class_is_what_we_match_not_the_message(self):
|
|
# Le message est traduit : « Accès refusé » en français. La
|
|
# CLASSE, elle, ne bouge pas.
|
|
self.assertTrue(
|
|
db_restore.password_refused("odoo.exceptions.AccessDenied")
|
|
)
|
|
self.assertFalse(db_restore.password_refused("Accès refusé"))
|
|
|
|
def test_anything_else_is_NOT_a_refusal(self):
|
|
# C'est la protection : dix invites de mot de passe devant une
|
|
# base cassée, et l'on cherche du mauvais côté.
|
|
for sortie in (
|
|
"psycopg2.OperationalError: could not connect",
|
|
"FileNotFoundError: ./odoo_bin.sh",
|
|
"",
|
|
None,
|
|
):
|
|
self.assertFalse(db_restore.password_refused(sortie), sortie)
|
|
|
|
|
|
class TestTheRetryLoop(unittest.TestCase):
|
|
def setUp(self):
|
|
self.vrais = (
|
|
db_restore.get_master_password,
|
|
db_restore.probe_master_password,
|
|
)
|
|
self.demandes = 0
|
|
self.sondes = []
|
|
|
|
def tearDown(self):
|
|
(
|
|
db_restore.get_master_password,
|
|
db_restore.probe_master_password,
|
|
) = self.vrais
|
|
|
|
def branche(self, mots, reponses):
|
|
suite = iter(mots)
|
|
rep = iter(reponses)
|
|
|
|
def demander():
|
|
self.demandes += 1
|
|
return next(suite, "")
|
|
|
|
def sonder(arg_base, mot):
|
|
self.sondes.append((arg_base, mot))
|
|
return next(rep, (False, "AccessDenied"))
|
|
|
|
db_restore.get_master_password = demander
|
|
db_restore.probe_master_password = sonder
|
|
|
|
def lance(self, essais=10):
|
|
tampon = io.StringIO()
|
|
with redirect_stdout(tampon), redirect_stderr(tampon):
|
|
return db_restore.ask_master_password("./odoo_bin.sh db", essais)
|
|
|
|
def test_a_good_password_is_returned_at_once(self):
|
|
self.branche(["bon"], [(True, "db1\ndb2")])
|
|
self.assertEqual(self.lance(), "bon")
|
|
self.assertEqual(self.demandes, 1)
|
|
|
|
def test_a_typo_is_asked_again(self):
|
|
self.branche(
|
|
["faux", "bon"],
|
|
[(False, "odoo.exceptions.AccessDenied"), (True, "db1")],
|
|
)
|
|
self.assertEqual(self.lance(), "bon")
|
|
self.assertEqual(self.demandes, 2)
|
|
|
|
def test_it_stops_after_the_allowed_attempts(self):
|
|
# Sans borne, une invite non lue tournerait toute la nuit.
|
|
self.branche(["faux"] * 20, [(False, "AccessDenied")] * 20)
|
|
self.assertIsNone(self.lance(essais=10))
|
|
self.assertEqual(self.demandes, 10)
|
|
|
|
def test_an_empty_prompt_gives_up_immediately(self):
|
|
# Ctrl-D ou Entrée : on ne veut pas neuf invites de plus.
|
|
self.branche([""], [])
|
|
self.assertIsNone(self.lance())
|
|
self.assertEqual(self.demandes, 1)
|
|
self.assertEqual(self.sondes, [])
|
|
|
|
def test_an_unrelated_failure_stops_instead_of_asking_again(self):
|
|
# LA propriété. Une base injoignable n'est pas un mot de passe
|
|
# faux, et redemander dix fois cacherait la vraie panne.
|
|
self.branche(["bon"], [(False, "psycopg2.OperationalError: refused")])
|
|
self.assertIsNone(self.lance())
|
|
self.assertEqual(self.demandes, 1)
|
|
|
|
def test_the_unrelated_failure_is_shown(self):
|
|
self.branche(["bon"], [(False, "psycopg2.OperationalError: refused")])
|
|
with self.assertLogs(db_restore._logger, level="ERROR") as journal:
|
|
db_restore.ask_master_password("./odoo_bin.sh db")
|
|
self.assertIn("OperationalError", "\n".join(journal.output))
|
|
|
|
def test_the_probe_carries_the_password_and_touches_nothing(self):
|
|
# `--list` ne modifie rien : valider ici évite d'échouer à
|
|
# mi-parcours, une fois la base déjà supprimée.
|
|
self.branche(["bon"], [(True, "db1")])
|
|
self.lance()
|
|
self.assertEqual(len(self.sondes), 1)
|
|
arg_base, mot = self.sondes[0]
|
|
self.assertEqual(mot, "bon")
|
|
# Le secret est passé À CÔTÉ de la commande, jamais dedans :
|
|
# /proc/<pid>/cmdline est lisible par tout utilisateur de la
|
|
# machine. C'est la garantie que ce test tient.
|
|
self.assertNotIn("bon", arg_base)
|
|
self.assertNotIn("--master_password", arg_base)
|
|
|
|
def test_each_attempt_probes_with_ITS_password(self):
|
|
self.branche(
|
|
["un", "deux"],
|
|
[(False, "AccessDenied"), (True, "db1")],
|
|
)
|
|
self.lance()
|
|
self.assertEqual([mot for _, mot in self.sondes], ["un", "deux"])
|
|
for arg_base, _ in self.sondes:
|
|
self.assertNotIn("--master_password", arg_base)
|
|
|
|
|
|
class TestTheWiring(unittest.TestCase):
|
|
def source(self):
|
|
with io.open(db_restore.__file__, encoding="utf-8") as handle:
|
|
return handle.read()
|
|
|
|
def test_the_flow_uses_the_retrying_version(self):
|
|
src = self.source()
|
|
self.assertIn("master_password = ask_master_password(arg_base)", src)
|
|
|
|
def test_the_probe_uses_list_which_changes_nothing(self):
|
|
src = self.source()
|
|
debut = src.index("def probe_master_password")
|
|
fin = src.index("def ask_master_password")
|
|
bloc = src[debut:fin]
|
|
self.assertIn("--list", bloc)
|
|
for danger in ("--drop", "--restore", "--clone"):
|
|
self.assertNotIn(danger, bloc)
|
|
|
|
def test_the_bound_is_ten(self):
|
|
self.assertEqual(db_restore.MAX_ESSAIS_MOT_DE_PASSE, 10)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|