From e214df7329d792a5d05af9ba6462fab1927c759a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 04:53:43 -0400 Subject: [PATCH] [FIX] smoke: judge the back office AFTER the repair, and say why it failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../odoo/migration/reset_stale_cow_views.py | 10 +- script/odoo/migration/smoke_internal_ui.py | 20 +++ script/odoo/migration/smoke_public_url.py | 111 ++++++++++++- script/todo/todo_i18n.py | 20 +++ test/test_smoke_internal_ui.py | 155 ++++++++++++++++++ 5 files changed, 312 insertions(+), 4 deletions(-) diff --git a/script/odoo/migration/reset_stale_cow_views.py b/script/odoo/migration/reset_stale_cow_views.py index 62bb2ee..ab11e79 100755 --- a/script/odoo/migration/reset_stale_cow_views.py +++ b/script/odoo/migration/reset_stale_cow_views.py @@ -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( diff --git a/script/odoo/migration/smoke_internal_ui.py b/script/odoo/migration/smoke_internal_ui.py index ccab3d1..25c3b0c 100755 --- a/script/odoo/migration/smoke_internal_ui.py +++ b/script/odoo/migration/smoke_internal_ui.py @@ -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} :" diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 8b246df..501fe76 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -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): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6f4464a..402c89f 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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?", diff --git a/test/test_smoke_internal_ui.py b/test/test_smoke_internal_ui.py index d12c85a..88e70da 100644 --- a/test/test_smoke_internal_ui.py +++ b/test/test_smoke_internal_ui.py @@ -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, "oups").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, "oups").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, "rien").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