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