diff --git a/script/odoo/migration/check_hidden_models.py b/script/odoo/migration/check_hidden_models.py index dc567ad..e693a15 100755 --- a/script/odoo/migration/check_hidden_models.py +++ b/script/odoo/migration/check_hidden_models.py @@ -91,6 +91,14 @@ try: ("share", "=", False), ], limit=LIMITE) rapport["users"] = membres.mapped("login") + # Les témoins ORDINAIRES : ceux qui ne sont pas administrateurs. + # Un modèle que seuls les administrateurs peuvent lire passe au vert + # si tous les témoins en sont — c'est exactement la forme du bug DMS, + # vue d'un autre angle. Mesuré sur une base réelle : les QUATRE + # utilisateurs internes étaient membres de base.group_system. + rapport["ordinary"] = [ + u.login for u in membres if not u.has_group("base.group_system") + ] if not membres: rapport["no_user"] = True else: @@ -99,6 +107,10 @@ try: .search([("global", "=", True), ("active", "=", True)]) .mapped("model_id.model") ) + # La COUVERTURE : combien de modèles portent une règle globale, + # et non combien on a pu éprouver. « 45 éprouvés » sans dire + # « sur 125 » laisse croire à un examen complet. + rapport["with_rule"] = len(vus - ATTENDUS) for nom in sorted(vus - ATTENDUS): modele = env.get(nom) if modele is None or modele._abstract or modele._transient: @@ -141,12 +153,24 @@ def render(rapport): if rapport.get("no_user"): return [f"⚠ {t('No internal user to test visibility with.')}"] muets = rapport.get("models") or [] + porteurs = rapport.get("with_rule") + sur = f" {t('out of')} {porteurs}" if porteurs else "" lignes = [ - f"🔍 {rapport.get('checked', 0)}" + f"🔍 {rapport.get('checked', 0)}{sur}" f" {t('model(s) with a global rule and some data,')}" f" {t('checked against')} {len(rapport.get('users') or [])}" f" {t('internal user(s)')}" ] + # Dire ce qu'on n'a PAS pu éprouver, avant de dire ce qu'on a trouvé. + # Un vert qui prouve moins qu'il n'en a l'air est pire qu'un rouge. + if "ordinary" in rapport and not rapport["ordinary"]: + lignes.append( + f" ⚠ {t('Every witness is an administrator: a model only')}" + f" {t('admins can read passes this test.')}" + ) + lignes.append( + f" ℹ {t('Reads ir_rule only — masking coded in Python escapes it.')}" + ) if not muets: lignes.append(f" ✅ {t('Every one of them is visible to someone.')}") return lignes diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 99c2844..0e9f9bc 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6789,6 +6789,22 @@ TRANSLATIONS = { "fr": "Aucun index en double.", "en": "No duplicate index.", }, + "out of": { + "fr": "sur", + "en": "out of", + }, + "Every witness is an administrator: a model only": { + "fr": "Tous les témoins sont administrateurs : un modèle que seuls les", + "en": "Every witness is an administrator: a model only", + }, + "admins can read passes this test.": { + "fr": "administrateurs peuvent lire passe ce test.", + "en": "admins can read passes this test.", + }, + "Reads ir_rule only — masking coded in Python escapes it.": { + "fr": "Ne lit qu'ir_rule — un masquage codé en Python lui échappe.", + "en": "Reads ir_rule only — masking coded in Python escapes it.", + }, "held no data of their own — nothing to lose": { "fr": "sans donnée propre — rien à perdre", "en": "held no data of their own — nothing to lose", diff --git a/test/test_check_hidden_models.py b/test/test_check_hidden_models.py index 75fe51f..f40a9e3 100644 --- a/test/test_check_hidden_models.py +++ b/test/test_check_hidden_models.py @@ -237,5 +237,99 @@ class TestTheWiring(unittest.TestCase): self.assertIn("--apply", src[debut : debut + 200]) +class TestItDeclaresWhatItCouldNotProve(unittest.TestCase): + """Un vert qui prouve moins qu'il n'en a l'air est pire qu'un rouge. + + Mesuré sur une base réelle : l'outil éprouvait 45 modèles sur 125 + portant une règle globale, contre 4 utilisateurs internes — et les + QUATRE étaient administrateurs. Un modèle que seuls les + administrateurs peuvent lire passait donc au vert. C'est exactement + la forme du bug DMS qui a motivé l'outil, vue d'un autre angle. + + L'outil ne peut pas créer un témoin ordinaire — ce serait une + écriture. Il peut dire qu'il n'en avait pas. + """ + + def rapport(self, **extra): + base = { + "checked": 45, + "with_rule": 125, + "users": ["a", "b", "c", "d"], + "ordinary": ["b"], + "models": [], + } + base.update(extra) + return base + + def tete(self, **extra): + return "\n".join(check.render(self.rapport(**extra))) + + def test_it_says_how_many_it_could_not_check(self): + # « 45 éprouvés » sans dire « sur 125 » laisse croire à un examen + # complet. + texte = self.tete() + self.assertIn("45", texte) + self.assertIn("125", texte) + + def test_without_the_coverage_it_says_only_what_it_checked(self): + # Un rapport d'une version antérieure n'a pas le renseignement. + # Inventer « sur 0 » serait pire que se taire — et « sur None » + # pire encore : c'est ce que produit un f-string sans garde. + texte = self.tete(with_rule=None) + self.assertIn("45", texte) + self.assertNotIn("125", texte) + self.assertNotIn("None", texte) + self.assertNotIn(check.t("out of"), texte.split("\n")[0]) + + def test_it_warns_when_every_witness_is_an_administrator(self): + texte = self.tete(ordinary=[]) + self.assertIn( + check.t("Every witness is an administrator: a model only"), texte + ) + + def test_it_stays_quiet_when_one_witness_is_ordinary(self): + self.assertNotIn( + check.t("Every witness is an administrator: a model only"), + self.tete(), + ) + + def test_it_declares_what_it_cannot_see_at_all(self): + # La sonde part d'ir_rule : un masquage écrit en Python lui + # échappe par construction, et le taire ferait prendre son vert + # pour une garantie. + self.assertIn( + check.t( + "Reads ir_rule only — masking coded in Python escapes it." + ), + self.tete(), + ) + + def test_the_warning_comes_before_the_verdict(self): + lignes = check.render(self.rapport(ordinary=[])) + avertissement = next( + i + for i, x in enumerate(lignes) + if "administrator" in x or "administrateur" in x + ) + verdict = next(i for i, x in enumerate(lignes) if "✅" in x) + self.assertLess(avertissement, verdict) + + +class TestTheScriptCollectsWhatTheReportNeeds(unittest.TestCase): + def corps(self): + return check.build_script() + + def test_it_counts_the_models_carrying_a_rule(self): + self.assertIn('rapport["with_rule"]', self.corps()) + + def test_it_separates_the_ordinary_witnesses(self): + corps = self.corps() + self.assertIn('rapport["ordinary"]', corps) + self.assertIn("base.group_system", corps) + + def test_the_script_is_valid_python(self): + compile(self.corps(), "script", "exec") + + if __name__ == "__main__": unittest.main()