[FIX] db_restore: ask the master password again instead of dying on a typo
It was asked once. Wrong, and Odoo raises AccessDenied, check_output
raises CalledProcessError, nothing catches it, and the migration dies on
a traceback. After an hour of version bumps that is a steep price for
one letter. Ten attempts now.
Only a refused PASSWORD is asked again. Any other failure stops and is
shown: asking ten times in front of an unreachable database would hide
the real fault behind a prompt, and one would hunt for a password.
AccessDenied is matched on the class, never on its message, which is
translated.
The attempt is probed with --list, which changes nothing. Validating
here avoids failing half-way, once the database has already been
dropped.
--- FR ---
Il était demandé une fois. Faux, et Odoo lève AccessDenied,
check_output lève CalledProcessError, rien ne l'attrape, la migration
meurt sur une trace. Après une heure de paliers, c'est cher payé pour
une lettre. Dix essais désormais.
Seul un MOT DE PASSE refusé fait reposer la question. Tout autre échec
arrête et s'affiche : dix invites devant une base injoignable
cacheraient la panne, et l'on chercherait un mot de passe. AccessDenied
se reconnaît à la CLASSE, jamais au message, qui est traduit.
L'essai est éprouvé sur --list, qui ne modifie rien. Valider là évite
d'échouer à mi-parcours, une fois la base déjà supprimée.
Assisted-by: Claude Opus 5
2026-08-22 03:36:22 -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)
|
|
|
|
|
|
|
|
|
|
"""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, "")
|
|
|
|
|
|
2026-08-23 00:32:37 -04:00
|
|
|
def sonder(arg_base, mot):
|
|
|
|
|
self.sondes.append((arg_base, mot))
|
[FIX] db_restore: ask the master password again instead of dying on a typo
It was asked once. Wrong, and Odoo raises AccessDenied, check_output
raises CalledProcessError, nothing catches it, and the migration dies on
a traceback. After an hour of version bumps that is a steep price for
one letter. Ten attempts now.
Only a refused PASSWORD is asked again. Any other failure stops and is
shown: asking ten times in front of an unreachable database would hide
the real fault behind a prompt, and one would hunt for a password.
AccessDenied is matched on the class, never on its message, which is
translated.
The attempt is probed with --list, which changes nothing. Validating
here avoids failing half-way, once the database has already been
dropped.
--- FR ---
Il était demandé une fois. Faux, et Odoo lève AccessDenied,
check_output lève CalledProcessError, rien ne l'attrape, la migration
meurt sur une trace. Après une heure de paliers, c'est cher payé pour
une lettre. Dix essais désormais.
Seul un MOT DE PASSE refusé fait reposer la question. Tout autre échec
arrête et s'affiche : dix invites devant une base injoignable
cacheraient la panne, et l'on chercherait un mot de passe. AccessDenied
se reconnaît à la CLASSE, jamais au message, qui est traduit.
L'essai est éprouvé sur --list, qui ne modifie rien. Valider là évite
d'échouer à mi-parcours, une fois la base déjà supprimée.
Assisted-by: Claude Opus 5
2026-08-22 03:36:22 -04:00
|
|
|
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):
|
[FIX] db_restore: la sonde du mot de passe maître ne validait rien
Elle interrogeait `db --list`, qui ne LIT jamais le mot de passe :
mesuré, `MASTER_PWD="ceci_est_faux" odoo-bin db --list` sort en 0. La
boucle des dix essais acceptait donc le premier mot saisi, juste ou
faux, et le refus n'arrivait qu'au `--restore` — une fois la base déjà
supprimée. C'était exactement ce que cette boucle devait éviter.
Seule l'action `drop` consulte le secret, et elle le fait AVANT de
regarder la base : `check_super` d'abord, `db_exists` ensuite. Sur un
nom tiré d'un uuid4, elle répond sans rien toucher. Mesuré : mauvais mot
de passe → code 1 et AccessDenied ; bon → code 0 ; huit bases avant,
huit après.
--- EN ---
It probed `db --list`, which never READS the master password: measured,
`MASTER_PWD="ceci_est_faux" odoo-bin db --list` exits 0. The ten-attempt
loop therefore accepted the first password typed, right or wrong, and
the refusal only came at `--restore` — once the database was already
dropped. Precisely what that loop existed to avoid.
Only the `drop` action reads the secret, and it does so BEFORE looking
at the database: `check_super` first, `db_exists` after. On a uuid4 name
it answers without touching anything. Measured: wrong password → exit 1
and AccessDenied; right → exit 0; eight databases before, eight after.
Assisted-by: Claude Opus 5
2026-08-24 04:55:59 -04:00
|
|
|
# Valider ici évite d'échouer à mi-parcours, une fois la base
|
|
|
|
|
# déjà supprimée. La sonde demande un `drop` sur un nom qui
|
|
|
|
|
# n'existe pas : `check_super` s'exécute AVANT `db_exists`, donc
|
|
|
|
|
# elle répond sans rien toucher.
|
[FIX] db_restore: ask the master password again instead of dying on a typo
It was asked once. Wrong, and Odoo raises AccessDenied, check_output
raises CalledProcessError, nothing catches it, and the migration dies on
a traceback. After an hour of version bumps that is a steep price for
one letter. Ten attempts now.
Only a refused PASSWORD is asked again. Any other failure stops and is
shown: asking ten times in front of an unreachable database would hide
the real fault behind a prompt, and one would hunt for a password.
AccessDenied is matched on the class, never on its message, which is
translated.
The attempt is probed with --list, which changes nothing. Validating
here avoids failing half-way, once the database has already been
dropped.
--- FR ---
Il était demandé une fois. Faux, et Odoo lève AccessDenied,
check_output lève CalledProcessError, rien ne l'attrape, la migration
meurt sur une trace. Après une heure de paliers, c'est cher payé pour
une lettre. Dix essais désormais.
Seul un MOT DE PASSE refusé fait reposer la question. Tout autre échec
arrête et s'affiche : dix invites devant une base injoignable
cacheraient la panne, et l'on chercherait un mot de passe. AccessDenied
se reconnaît à la CLASSE, jamais au message, qui est traduit.
L'essai est éprouvé sur --list, qui ne modifie rien. Valider là évite
d'échouer à mi-parcours, une fois la base déjà supprimée.
Assisted-by: Claude Opus 5
2026-08-22 03:36:22 -04:00
|
|
|
self.branche(["bon"], [(True, "db1")])
|
|
|
|
|
self.lance()
|
|
|
|
|
self.assertEqual(len(self.sondes), 1)
|
2026-08-23 00:32:37 -04:00
|
|
|
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)
|
[FIX] db_restore: ask the master password again instead of dying on a typo
It was asked once. Wrong, and Odoo raises AccessDenied, check_output
raises CalledProcessError, nothing catches it, and the migration dies on
a traceback. After an hour of version bumps that is a steep price for
one letter. Ten attempts now.
Only a refused PASSWORD is asked again. Any other failure stops and is
shown: asking ten times in front of an unreachable database would hide
the real fault behind a prompt, and one would hunt for a password.
AccessDenied is matched on the class, never on its message, which is
translated.
The attempt is probed with --list, which changes nothing. Validating
here avoids failing half-way, once the database has already been
dropped.
--- FR ---
Il était demandé une fois. Faux, et Odoo lève AccessDenied,
check_output lève CalledProcessError, rien ne l'attrape, la migration
meurt sur une trace. Après une heure de paliers, c'est cher payé pour
une lettre. Dix essais désormais.
Seul un MOT DE PASSE refusé fait reposer la question. Tout autre échec
arrête et s'affiche : dix invites devant une base injoignable
cacheraient la panne, et l'on chercherait un mot de passe. AccessDenied
se reconnaît à la CLASSE, jamais au message, qui est traduit.
L'essai est éprouvé sur --list, qui ne modifie rien. Valider là évite
d'échouer à mi-parcours, une fois la base déjà supprimée.
Assisted-by: Claude Opus 5
2026-08-22 03:36:22 -04:00
|
|
|
|
|
|
|
|
def test_each_attempt_probes_with_ITS_password(self):
|
|
|
|
|
self.branche(
|
|
|
|
|
["un", "deux"],
|
|
|
|
|
[(False, "AccessDenied"), (True, "db1")],
|
|
|
|
|
)
|
|
|
|
|
self.lance()
|
2026-08-23 00:32:37 -04:00
|
|
|
self.assertEqual([mot for _, mot in self.sondes], ["un", "deux"])
|
|
|
|
|
for arg_base, _ in self.sondes:
|
|
|
|
|
self.assertNotIn("--master_password", arg_base)
|
[FIX] db_restore: ask the master password again instead of dying on a typo
It was asked once. Wrong, and Odoo raises AccessDenied, check_output
raises CalledProcessError, nothing catches it, and the migration dies on
a traceback. After an hour of version bumps that is a steep price for
one letter. Ten attempts now.
Only a refused PASSWORD is asked again. Any other failure stops and is
shown: asking ten times in front of an unreachable database would hide
the real fault behind a prompt, and one would hunt for a password.
AccessDenied is matched on the class, never on its message, which is
translated.
The attempt is probed with --list, which changes nothing. Validating
here avoids failing half-way, once the database has already been
dropped.
--- FR ---
Il était demandé une fois. Faux, et Odoo lève AccessDenied,
check_output lève CalledProcessError, rien ne l'attrape, la migration
meurt sur une trace. Après une heure de paliers, c'est cher payé pour
une lettre. Dix essais désormais.
Seul un MOT DE PASSE refusé fait reposer la question. Tout autre échec
arrête et s'affiche : dix invites devant une base injoignable
cacheraient la panne, et l'on chercherait un mot de passe. AccessDenied
se reconnaît à la CLASSE, jamais au message, qui est traduit.
L'essai est éprouvé sur --list, qui ne modifie rien. Valider là évite
d'échouer à mi-parcours, une fois la base déjà supprimée.
Assisted-by: Claude Opus 5
2026-08-22 03:36:22 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
[FIX] db_restore: la sonde du mot de passe maître ne validait rien
Elle interrogeait `db --list`, qui ne LIT jamais le mot de passe :
mesuré, `MASTER_PWD="ceci_est_faux" odoo-bin db --list` sort en 0. La
boucle des dix essais acceptait donc le premier mot saisi, juste ou
faux, et le refus n'arrivait qu'au `--restore` — une fois la base déjà
supprimée. C'était exactement ce que cette boucle devait éviter.
Seule l'action `drop` consulte le secret, et elle le fait AVANT de
regarder la base : `check_super` d'abord, `db_exists` ensuite. Sur un
nom tiré d'un uuid4, elle répond sans rien toucher. Mesuré : mauvais mot
de passe → code 1 et AccessDenied ; bon → code 0 ; huit bases avant,
huit après.
--- EN ---
It probed `db --list`, which never READS the master password: measured,
`MASTER_PWD="ceci_est_faux" odoo-bin db --list` exits 0. The ten-attempt
loop therefore accepted the first password typed, right or wrong, and
the refusal only came at `--restore` — once the database was already
dropped. Precisely what that loop existed to avoid.
Only the `drop` action reads the secret, and it does so BEFORE looking
at the database: `check_super` first, `db_exists` after. On a uuid4 name
it answers without touching anything. Measured: wrong password → exit 1
and AccessDenied; right → exit 0; eight databases before, eight after.
Assisted-by: Claude Opus 5
2026-08-24 04:55:59 -04:00
|
|
|
def bloc_sonde(self):
|
|
|
|
|
"""Le CODE de la sonde, docstring retirée.
|
|
|
|
|
|
|
|
|
|
La docstring EXPLIQUE pourquoi `--list` et `--restore` ne
|
|
|
|
|
conviennent pas : un test qui lit tout le texte les y trouve et
|
|
|
|
|
se croit trompé. Troisième fois que ce piège se referme sur moi
|
|
|
|
|
dans ce dépôt — voir tasks/lessons.md.
|
|
|
|
|
"""
|
|
|
|
|
import ast
|
|
|
|
|
|
|
|
|
|
for noeud in ast.walk(ast.parse(self.source())):
|
|
|
|
|
if (
|
|
|
|
|
isinstance(noeud, ast.FunctionDef)
|
|
|
|
|
and noeud.name == "probe_master_password"
|
|
|
|
|
):
|
|
|
|
|
sans_texte = [
|
|
|
|
|
n
|
|
|
|
|
for n in noeud.body
|
|
|
|
|
if not (
|
|
|
|
|
isinstance(n, ast.Expr)
|
|
|
|
|
and isinstance(n.value, ast.Constant)
|
|
|
|
|
and isinstance(n.value.value, str)
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
return ast.dump(ast.Module(body=sans_texte, type_ignores=[]))
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
def test_the_scan_finds_the_probe_at_all(self):
|
|
|
|
|
# Sans cette borne, les tests suivants passeraient sur une chaîne
|
|
|
|
|
# vide le jour où la fonction est renommée.
|
|
|
|
|
self.assertTrue(self.bloc_sonde())
|
|
|
|
|
|
|
|
|
|
def test_the_probe_uses_the_only_action_that_reads_the_password(self):
|
|
|
|
|
# `--list` ne LIT jamais le mot de passe : mesuré,
|
|
|
|
|
# MASTER_PWD="ceci_est_faux" … db --list sort en 0. La sonde
|
|
|
|
|
# d'avant acceptait donc le premier mot saisi, et la boucle des
|
|
|
|
|
# dix essais ne servait à rien. Seul `drop` consulte le secret.
|
|
|
|
|
bloc = self.bloc_sonde()
|
|
|
|
|
self.assertIn("--drop", bloc)
|
|
|
|
|
self.assertNotIn("--list", bloc)
|
|
|
|
|
|
|
|
|
|
def test_it_never_names_a_database_that_could_exist(self):
|
|
|
|
|
# La sonde DEMANDE une suppression : sur un vrai nom et avec un
|
|
|
|
|
# bon mot de passe, on perdrait la base. Le nom vient d'un uuid.
|
|
|
|
|
bloc = self.bloc_sonde()
|
|
|
|
|
# L'arbre nomme les appels : `Call(func=Name(id='probe_name'))`.
|
|
|
|
|
self.assertIn("id='probe_name'", bloc)
|
|
|
|
|
# …et jamais la base que l'appelant vise.
|
|
|
|
|
self.assertNotIn("id='database'", bloc)
|
|
|
|
|
|
|
|
|
|
def test_it_never_asks_to_restore_or_clone(self):
|
|
|
|
|
bloc = self.bloc_sonde()
|
|
|
|
|
for danger in ("--restore", "--clone"):
|
[FIX] db_restore: ask the master password again instead of dying on a typo
It was asked once. Wrong, and Odoo raises AccessDenied, check_output
raises CalledProcessError, nothing catches it, and the migration dies on
a traceback. After an hour of version bumps that is a steep price for
one letter. Ten attempts now.
Only a refused PASSWORD is asked again. Any other failure stops and is
shown: asking ten times in front of an unreachable database would hide
the real fault behind a prompt, and one would hunt for a password.
AccessDenied is matched on the class, never on its message, which is
translated.
The attempt is probed with --list, which changes nothing. Validating
here avoids failing half-way, once the database has already been
dropped.
--- FR ---
Il était demandé une fois. Faux, et Odoo lève AccessDenied,
check_output lève CalledProcessError, rien ne l'attrape, la migration
meurt sur une trace. Après une heure de paliers, c'est cher payé pour
une lettre. Dix essais désormais.
Seul un MOT DE PASSE refusé fait reposer la question. Tout autre échec
arrête et s'affiche : dix invites devant une base injoignable
cacheraient la panne, et l'on chercherait un mot de passe. AccessDenied
se reconnaît à la CLASSE, jamais au message, qui est traduit.
L'essai est éprouvé sur --list, qui ne modifie rien. Valider là évite
d'échouer à mi-parcours, une fois la base déjà supprimée.
Assisted-by: Claude Opus 5
2026-08-22 03:36:22 -04:00
|
|
|
self.assertNotIn(danger, bloc)
|
|
|
|
|
|
|
|
|
|
def test_the_bound_is_ten(self):
|
|
|
|
|
self.assertEqual(db_restore.MAX_ESSAIS_MOT_DE_PASSE, 10)
|
|
|
|
|
|
|
|
|
|
|
[FIX] db_restore: la sonde du mot de passe maître ne validait rien
Elle interrogeait `db --list`, qui ne LIT jamais le mot de passe :
mesuré, `MASTER_PWD="ceci_est_faux" odoo-bin db --list` sort en 0. La
boucle des dix essais acceptait donc le premier mot saisi, juste ou
faux, et le refus n'arrivait qu'au `--restore` — une fois la base déjà
supprimée. C'était exactement ce que cette boucle devait éviter.
Seule l'action `drop` consulte le secret, et elle le fait AVANT de
regarder la base : `check_super` d'abord, `db_exists` ensuite. Sur un
nom tiré d'un uuid4, elle répond sans rien toucher. Mesuré : mauvais mot
de passe → code 1 et AccessDenied ; bon → code 0 ; huit bases avant,
huit après.
--- EN ---
It probed `db --list`, which never READS the master password: measured,
`MASTER_PWD="ceci_est_faux" odoo-bin db --list` exits 0. The ten-attempt
loop therefore accepted the first password typed, right or wrong, and
the refusal only came at `--restore` — once the database was already
dropped. Precisely what that loop existed to avoid.
Only the `drop` action reads the secret, and it does so BEFORE looking
at the database: `check_super` first, `db_exists` after. On a uuid4 name
it answers without touching anything. Measured: wrong password → exit 1
and AccessDenied; right → exit 0; eight databases before, eight after.
Assisted-by: Claude Opus 5
2026-08-24 04:55:59 -04:00
|
|
|
class TestTheProbeName(unittest.TestCase):
|
|
|
|
|
"""Le nom que la sonde demande de supprimer.
|
|
|
|
|
|
|
|
|
|
C'est le seul endroit de cet outil où une erreur coûterait une base.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_it_is_prefixed_so_a_log_says_where_it_came_from(self):
|
|
|
|
|
self.assertTrue(db_restore.probe_name().startswith("el_probe_"))
|
|
|
|
|
|
|
|
|
|
def test_two_calls_never_give_the_same_name(self):
|
|
|
|
|
noms = {db_restore.probe_name() for _ in range(50)}
|
|
|
|
|
self.assertEqual(50, len(noms))
|
|
|
|
|
|
|
|
|
|
def test_it_is_long_enough_to_be_unguessable(self):
|
|
|
|
|
# Un uuid4 en hexadécimal : 32 caractères après le préfixe.
|
|
|
|
|
self.assertGreaterEqual(
|
|
|
|
|
len(db_restore.probe_name()), len("el_probe_") + 32
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_it_is_a_legal_postgresql_identifier(self):
|
|
|
|
|
# Un nom que PostgreSQL refuserait ferait échouer la sonde pour
|
|
|
|
|
# une raison qui n'a rien à voir avec le mot de passe, et l'on
|
|
|
|
|
# redemanderait dix fois un secret parfaitement bon.
|
|
|
|
|
self.assertRegex(db_restore.probe_name(), r"^[a-z][a-z0-9_]{0,62}$")
|
|
|
|
|
|
|
|
|
|
|
[FIX] db_restore: ask the master password again instead of dying on a typo
It was asked once. Wrong, and Odoo raises AccessDenied, check_output
raises CalledProcessError, nothing catches it, and the migration dies on
a traceback. After an hour of version bumps that is a steep price for
one letter. Ten attempts now.
Only a refused PASSWORD is asked again. Any other failure stops and is
shown: asking ten times in front of an unreachable database would hide
the real fault behind a prompt, and one would hunt for a password.
AccessDenied is matched on the class, never on its message, which is
translated.
The attempt is probed with --list, which changes nothing. Validating
here avoids failing half-way, once the database has already been
dropped.
--- FR ---
Il était demandé une fois. Faux, et Odoo lève AccessDenied,
check_output lève CalledProcessError, rien ne l'attrape, la migration
meurt sur une trace. Après une heure de paliers, c'est cher payé pour
une lettre. Dix essais désormais.
Seul un MOT DE PASSE refusé fait reposer la question. Tout autre échec
arrête et s'affiche : dix invites devant une base injoignable
cacheraient la panne, et l'on chercherait un mot de passe. AccessDenied
se reconnaît à la CLASSE, jamais au message, qui est traduit.
L'essai est éprouvé sur --list, qui ne modifie rien. Valider là évite
d'échouer à mi-parcours, une fois la base déjà supprimée.
Assisted-by: Claude Opus 5
2026-08-22 03:36:22 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|