[FIX] smoke: judge the back office AFTER the repair, and say why it failed

No timeout was hit and no test was skipped. The back office failed for the
same reason the site did: the COW copy of website.submenu breaks
/web/login exactly as it breaks the public pages — both go through the
same layout. The pass simply ran BEFORE the reset, and unlike the URLs it
was never looked at again. It is now, on the same second server.

« Session expired » described the consequence and hid the cause. The login
page returned 500 and nothing said so, so one looks at the password. The
status of that page, and a missing CSRF token, are now named.

Diagnosing this, I started Odoo 18 on a 17 database and got 500 on all
thirty-seven URLs and on /web/login: a report that says the site is
entirely broken when nothing is. database_cleanup already refused that;
the smoke tools now share the same guard rather than reimplementing it.

--- FR ---

[FIX] smoke : juger le back-office APRÈS la réparation, et dire pourquoi

Aucun délai n'a été atteint et aucun test n'a été sauté. Le back-office a
échoué pour la raison même qui cassait le site : la copie COW de
website.submenu casse /web/login comme elle casse les pages publiques —
les deux passent par le même gabarit. La passe tournait avant la
réinitialisation et, contrairement aux URL, n'était jamais revue.

« Session expired » décrivait la conséquence et cachait la cause : la page
de connexion rendait 500 sans que rien ne le dise, et l'on cherchait du
côté du mot de passe.

En diagnostiquant, j'ai lancé un Odoo 18 sur une base 17 : 500 partout, un
rapport qui déclare le site entièrement cassé quand rien ne l'est.
database_cleanup refusait déjà cela ; les outils de fumée partagent
désormais sa garde au lieu d'en écrire une seconde.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-19 04:53:43 -04:00
parent 19d807a81e
commit e214df7329
5 changed files with 312 additions and 4 deletions

View file

@ -392,7 +392,15 @@ def main():
print(f"✅ {t('No COW copy has drifted from its module view.')}")
return 0
if not findings:
print(f"ℹ {t('No COW copy has drifted from its module view.')}")
# Le même texte que ci-dessus se lisait comme une contradiction :
# « aucune copie n'a dérivé », puis « ✅ réinitialisé id=2656 ».
# Les deux sont vrais — la détection différentielle ne voit qu'une
# copie dont un ENFANT casse — mais mis côte à côte sans un mot,
# on croit l'outil incohérent et l'on cesse de le lire.
print(
f"ℹ {t('The differential detection found nothing; resetting')}"
f" {t('the requested key(s) anyway.')}"
)
if findings:
print(

View file

@ -211,7 +211,21 @@ class Session:
status, body = self.open("/web/login")
if not status:
return False, t("The server did not serve the login page.")
if status >= 400:
# DIRE le statut. Sans cela on rapportait « Session expired »,
# qui décrit la conséquence et cache la cause : la page de
# connexion elle-même rendait 500. Mesuré — une copie COW de
# `website.submenu` casse /web/login comme elle casse le site,
# et l'on cherchait du côté du mot de passe.
return False, (
f"{t('The login page itself failed')} : HTTP {status} —"
f" {error_from_page(body)}"
)
match = RE_CSRF.search(body)
if not match:
# Sans jeton, Odoo refuse le POST et rend la page de connexion
# avec un statut 200 : on croirait à un mot de passe refusé.
return False, t("No CSRF token on the login page")
fields = {
"login": login,
"password": password,
@ -942,6 +956,12 @@ def main(argv=None):
f" {t('the database was not neutralized, nothing to browse.')}"
)
return 0
from smoke_public_url import require_matching_version
mismatch = require_matching_version(config.database)
if mismatch:
print(f"⛔ {mismatch}")
return 2
if port_is_taken(port):
print(
f"❌ {t('Something already listens on port')} {port} :"

View file

@ -108,6 +108,27 @@ def run_psql(database, sql):
return [line.split("\x1f") for line in done.stdout.splitlines() if line]
def require_matching_version(database):
"""Refuser de démarrer sur une base d'une autre version qu'un Odoo.
Sans cela le rapport ment de la pire façon : mesuré ici même, un
checkout passé en 18.0 démarré sur une base 17.0 rend 500 sur les
trente-sept URL et sur /web/login, et l'on conclut à un site
entièrement cassé alors que rien ne l'est. Le checkout suit la
migration ; rien ne garantit qu'il soit resté sur la version de la
base qu'on veut interroger.
On délègue à `database_cleanup`, qui porte déjà cette garde : deux
implémentations divergeraient, et une garde qui diverge ne garde rien.
"""
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
from database_cleanup import require_matching_version as verifier
except ImportError:
return None
return verifier(database)
def start_server(database, port, config_path="./config.conf", log_path=None):
"""Démarrer Odoo, son journal dans un FICHIER.
@ -444,16 +465,21 @@ def render_internal(internal):
"""
if internal is None:
return False
reprise = f" ({t('after the reset')})" if internal.get("retried") else ""
if "skipped" in internal:
if internal.get("loud"):
# Un saut ATTENDU se dit à voix basse ; un saut qui trahit une
# panne doit compter comme un échec, sinon le code de sortie
# annonce que tout va bien.
print(
f"\n⚠️ {t('Back office NOT browsed')} : {internal['skipped']}"
f"\n⚠️ {t('Back office NOT browsed')}{reprise} :"
f" {internal['skipped']}"
)
return True
print(f"\nℹ️ {t('Back office not browsed')} : {internal['skipped']}")
print(
f"\nℹ️ {t('Back office not browsed')}{reprise} :"
f" {internal['skipped']}"
)
return False
import smoke_internal_ui
@ -480,6 +506,20 @@ def attach_internal_log(internal_report, log_path):
)
def internal_needs_retry(internal_report):
"""Le back-office mérite-t-il un second essai après la réparation ?
Oui s'il a échoué, et oui aussi s'il a été SAUTÉ : « connexion
impossible » est justement le symptôme d'un site cassé, et c'est ce
que la réinitialisation vient de réparer.
"""
if not internal_report:
return False
return bool(
internal_report.get("failures") or internal_report.get("skipped")
)
def internal_phase(
base_url,
database,
@ -584,6 +624,9 @@ def run(
log_path = os.path.join(
tempfile.gettempdir(), f"erplibre_smoke_{database}_{port}.log"
)
mismatch = require_matching_version(database)
if mismatch:
raise RuntimeError(mismatch)
if port_is_taken(port):
raise RuntimeError(
f"{t('Something already listens on port')} {port} :"
@ -650,6 +693,53 @@ def run(
if not lst_done:
return lst_url, lst_failure, lst_key, None, internal_report
lst_again, internal_report = recheck_after_reset(
database,
port,
config_path,
base_url,
log_path,
lst_failure,
internal_report,
timeout=timeout,
boot=boot,
internal=internal,
internal_login=internal_login,
internal_password=internal_password,
internal_limit=internal_limit,
every_menu=every_menu,
portal=portal,
internal_required=internal_required,
)
return lst_url, lst_failure, lst_key, lst_again, internal_report
def recheck_after_reset(
database,
port,
config_path,
base_url,
log_path,
lst_failure,
internal_report,
timeout=30,
boot=180,
internal=True,
internal_login="test",
internal_password="test",
internal_limit=20,
every_menu=False,
portal=None,
internal_required=False,
):
"""Redémarrer, et revoir ce qui avait échoué. Rend (URL, back-office).
Le back-office est REJUGÉ lui aussi, et ce n'est pas une symétrie
gratuite : la copie COW qui casse le site casse AUSSI /web/login — les
deux passent par le même gabarit. La passe interne tournait donc avant
la réparation et rapportait « connexion impossible » sur une base que
la réinitialisation remettait d'aplomb quelques secondes plus tard.
"""
server = start_server(database, port, config_path, log_path=log_path)
try:
if not wait_ready(base_url, timeout=boot):
@ -659,9 +749,24 @@ def run(
lst_again = check_urls(
[url for url, _s, _p in lst_failure], timeout=timeout
)
if internal_needs_retry(internal_report):
reprise = internal_phase(
base_url,
database,
enabled=internal,
login=internal_login,
password=internal_password,
limit=internal_limit,
every_menu=every_menu,
lst_portal=portal,
required=internal_required,
)
if reprise is not None:
internal_report = reprise
internal_report["retried"] = True
finally:
stop_server(server)
return lst_url, lst_failure, lst_key, lst_again, internal_report
return lst_again, internal_report
def stop_server(server):

View file

@ -5615,6 +5615,26 @@ TRANSLATIONS = {
"fr": "ou tapez nomfichier.zip",
"en": "or type filename.zip",
},
"The login page itself failed": {
"fr": "La page de connexion elle-même a échoué",
"en": "The login page itself failed",
},
"No CSRF token on the login page": {
"fr": "Aucun jeton CSRF sur la page de connexion",
"en": "No CSRF token on the login page",
},
"after the reset": {
"fr": "après la réinitialisation",
"en": "after the reset",
},
"The differential detection found nothing; resetting": {
"fr": "La détection différentielle n'a rien trouvé ; réinitialisation",
"en": "The differential detection found nothing; resetting",
},
"the requested key(s) anyway.": {
"fr": "de la ou des clés demandées malgré tout.",
"en": "the requested key(s) anyway.",
},
"Clean the database before testing the pages?": {
"fr": "Nettoyer la base avant de tester les pages ?",
"en": "Clean the database before testing the pages?",

View file

@ -504,6 +504,161 @@ class TestASkipThatHidesAFailure(unittest.TestCase):
)
class TestSayingWHYTheLoginFailed(unittest.TestCase):
"""« Session expired » décrit la conséquence et cache la cause.
Vécu : la passe back-office rapportait « Connexion impossible en tant
que test : SessionExpiredException », et l'on a cherché du côté du mot
de passe pendant que la vraie cause était sous les yeux — /web/login
rendait 500. Une copie COW de `website.submenu` casse la page de
connexion comme elle casse le site : les deux passent par le même
gabarit.
"""
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def session(self, status, body):
session = ui.Session("http://x")
session.open = lambda path, data=None, headers=None: (status, body)
return session
def test_a_broken_login_page_is_named_as_such(self):
ok, raison = self.session(500, "<html>oups</html>").log_in(
"db", "test", "test"
)
self.assertFalse(ok)
self.assertIn("login page itself failed", raison)
self.assertIn("500", raison)
def test_it_does_not_blame_the_password(self):
_ok, raison = self.session(500, "<html>oups</html>").log_in(
"db", "test", "test"
)
self.assertNotIn("password", raison.lower())
def test_a_page_without_a_token_is_named_too(self):
# Sans jeton, Odoo refuse le POST et rend la page de connexion avec
# un statut 200 : on croirait à un mot de passe refusé.
ok, raison = self.session(200, "<html>rien</html>").log_in(
"db", "test", "test"
)
self.assertFalse(ok)
self.assertIn("CSRF", raison)
def test_a_dead_server_is_still_distinguished(self):
ok, raison = self.session(0, "").log_in("db", "test", "test")
self.assertFalse(ok)
self.assertIn("did not serve", raison)
class TestTheBackOfficeIsJudgedAfterTheRepair(unittest.TestCase):
"""La passe interne tournait AVANT la réinitialisation.
Elle rapportait donc « connexion impossible » sur une base que la
réparation remettait d'aplomb quelques secondes plus tard — et rien ne
corrigeait le rapport. Les URL, elles, étaient déjà revérifiées.
"""
def setUp(self):
from script.todo import todo_i18n
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def test_a_failed_pass_deserves_a_second_look(self):
import smoke_public_url as public
self.assertTrue(public.internal_needs_retry({"failures": [{"x": 1}]}))
def test_a_SKIPPED_pass_too(self):
# « connexion impossible » est justement le symptôme d'un site
# cassé, c'est-à-dire de ce qu'on vient de réparer.
import smoke_public_url as public
self.assertTrue(public.internal_needs_retry({"skipped": "raison"}))
def test_a_healthy_pass_is_not_redone(self):
import smoke_public_url as public
self.assertFalse(
public.internal_needs_retry({"results": [1], "failures": []})
)
def test_no_pass_at_all_is_not_redone(self):
import smoke_public_url as public
self.assertFalse(public.internal_needs_retry(None))
def test_the_retry_happens_while_the_second_server_is_up(self):
import inspect
import smoke_public_url as public
source = inspect.getsource(public.recheck_after_reset)
self.assertLess(
source.index("internal_needs_retry"),
source.index("stop_server(server)"),
)
def test_the_report_says_it_was_a_second_look(self):
import contextlib
import io
import smoke_public_url as public
out = io.StringIO()
with contextlib.redirect_stdout(out):
public.render_internal(
{"skipped": "raison", "loud": True, "retried": True}
)
self.assertIn("after the reset", out.getvalue())
class TestItRefusesTheWrongOdooVersion(unittest.TestCase):
"""Mesuré en diagnostiquant ce même incident, et je m'y suis pris.
Un checkout passé en 18.0 démarré sur une base 17.0 rend 500 sur les
trente-sept URL ET sur /web/login. On conclut à un site entièrement
cassé alors que rien ne l'est — et Odoo écrit en chemin.
"""
def test_the_smoke_tool_has_the_guard(self):
import inspect
import smoke_public_url as public
source = inspect.getsource(public.run)
self.assertIn("require_matching_version", source)
self.assertLess(
source.index("require_matching_version"),
source.index("start_server("),
)
def test_the_internal_tool_has_it_too(self):
import inspect
source = inspect.getsource(ui.main)
self.assertIn("require_matching_version", source)
def test_the_guard_is_not_reimplemented(self):
# Deux implémentations divergeraient, et une garde qui diverge ne
# garde rien.
import inspect
import smoke_public_url as public
source = inspect.getsource(public.require_matching_version)
self.assertIn("from database_cleanup import", source)
class TestThePortalPage(unittest.TestCase):
"""/my n'est ni le site public ni le back-office : c'est un troisième
rendu, en QWeb frontend, avec ses compteurs qui interrogent chacun leur